diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php new file mode 100644 index 0000000..fe7055c --- /dev/null +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -0,0 +1,277 @@ +|null */ + public ?array $severityCounts = null; + + public static function canAccess(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); + } + + public function mount(): void + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + $this->state = 'no_tenant'; + $this->message = 'No tenant selected.'; + + return; + } + + $assignment = BaselineTenantAssignment::query() + ->where('tenant_id', $tenant->getKey()) + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment) { + $this->state = 'no_assignment'; + $this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.'; + + return; + } + + $profile = $assignment->baselineProfile; + + if ($profile === null) { + $this->state = 'no_assignment'; + $this->message = 'The assigned baseline profile no longer exists.'; + + return; + } + + $this->profileName = (string) $profile->name; + $this->profileId = (int) $profile->getKey(); + $this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; + + if ($this->snapshotId === null) { + $this->state = 'no_snapshot'; + $this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.'; + + return; + } + + $latestRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'baseline_compare') + ->latest('id') + ->first(); + + if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { + $this->state = 'comparing'; + $this->operationRunId = (int) $latestRun->getKey(); + $this->message = 'A baseline comparison is currently in progress.'; + + return; + } + + $scopeKey = 'baseline_profile:' . $profile->getKey(); + + $findingsQuery = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey); + + $totalFindings = (int) (clone $findingsQuery)->count(); + + if ($totalFindings > 0) { + $this->state = 'ready'; + $this->findingsCount = $totalFindings; + $this->severityCounts = [ + 'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(), + 'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(), + 'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(), + ]; + + if ($latestRun instanceof OperationRun) { + $this->operationRunId = (int) $latestRun->getKey(); + } + + return; + } + + if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') { + $this->state = 'ready'; + $this->findingsCount = 0; + $this->operationRunId = (int) $latestRun->getKey(); + $this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.'; + + return; + } + + $this->state = 'idle'; + $this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.'; + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + $this->compareNowAction(), + ]; + } + + private function compareNowAction(): Action + { + return Action::make('compareNow') + ->label('Compare Now') + ->icon('heroicon-o-play') + ->requiresConfirmation() + ->modalHeading('Start baseline comparison') + ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.') + ->visible(fn (): bool => $this->canCompare()) + ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true)) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + Notification::make()->title('Not authenticated')->danger()->send(); + + return; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + Notification::make()->title('No tenant context')->danger()->send(); + + return; + } + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + if (! ($result['ok'] ?? false)) { + Notification::make() + ->title('Cannot start comparison') + ->body('Reason: ' . ($result['reason_code'] ?? 'unknown')) + ->danger() + ->send(); + + return; + } + + $run = $result['run'] ?? null; + + if ($run instanceof OperationRun) { + $this->operationRunId = (int) $run->getKey(); + } + + $this->state = 'comparing'; + + Notification::make() + ->title('Baseline comparison started') + ->body('A background job will compute drift against the baseline snapshot.') + ->success() + ->send(); + }); + } + + private function canCompare(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); + } + + public function getFindingsUrl(): ?string + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return FindingResource::getUrl('index', tenant: $tenant); + } + + public function getRunUrl(): ?string + { + if ($this->operationRunId === null) { + return null; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return OperationRunLinks::view($this->operationRunId, $tenant); + } +} diff --git a/app/Filament/Pages/TenantDashboard.php b/app/Filament/Pages/TenantDashboard.php index defca63..bacefac 100644 --- a/app/Filament/Pages/TenantDashboard.php +++ b/app/Filament/Pages/TenantDashboard.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages; +use App\Filament\Widgets\Dashboard\BaselineCompareNow; use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\RecentDriftFindings; @@ -31,6 +32,7 @@ public function getWidgets(): array return [ DashboardKpis::class, NeedsAttention::class, + BaselineCompareNow::class, RecentDriftFindings::class, RecentOperations::class, ]; diff --git a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php index 302d70d..2342391 100644 --- a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +++ b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php @@ -4,10 +4,21 @@ namespace App\Filament\Resources\BaselineProfileResource\RelationManagers; +use App\Models\BaselineProfile; +use App\Models\BaselineTenantAssignment; +use App\Models\Tenant; +use App\Models\User; +use App\Models\Workspace; +use App\Services\Audit\WorkspaceAuditLogger; +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 Filament\Actions\Action; +use Filament\Forms\Components\Select; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -43,7 +54,193 @@ public function table(Table $table): Table ->dateTime() ->sortable(), ]) + ->headerActions([ + $this->assignTenantAction(), + ]) + ->actions([ + $this->removeAssignmentAction(), + ]) ->emptyStateHeading('No tenants assigned') - ->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.'); + ->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.') + ->emptyStateActions([ + $this->assignTenantAction(), + ]); + } + + private function assignTenantAction(): Action + { + return Action::make('assign') + ->label('Assign Tenant') + ->icon('heroicon-o-plus') + ->visible(fn (): bool => $this->hasManageCapability()) + ->form([ + Select::make('tenant_id') + ->label('Tenant') + ->options(fn (): array => $this->getAvailableTenantOptions()) + ->required() + ->searchable(), + ]) + ->action(function (array $data): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $this->hasManageCapability()) { + Notification::make() + ->title('Permission denied') + ->danger() + ->send(); + + return; + } + + /** @var BaselineProfile $profile */ + $profile = $this->getOwnerRecord(); + $tenantId = (int) $data['tenant_id']; + + $existing = BaselineTenantAssignment::query() + ->where('workspace_id', $profile->workspace_id) + ->where('tenant_id', $tenantId) + ->first(); + + if ($existing instanceof BaselineTenantAssignment) { + Notification::make() + ->title('Tenant already assigned') + ->body('This tenant already has a baseline assignment in this workspace.') + ->warning() + ->send(); + + return; + } + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $profile->workspace_id, + 'tenant_id' => $tenantId, + 'baseline_profile_id' => (int) $profile->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + $this->auditAssignment($profile, $assignment, $user, 'created'); + + Notification::make() + ->title('Tenant assigned') + ->success() + ->send(); + }); + } + + private function removeAssignmentAction(): Action + { + return Action::make('remove') + ->label('Remove') + ->icon('heroicon-o-trash') + ->color('danger') + ->visible(fn (): bool => $this->hasManageCapability()) + ->requiresConfirmation() + ->modalHeading('Remove tenant assignment') + ->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.') + ->action(function (BaselineTenantAssignment $record): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $this->hasManageCapability()) { + Notification::make() + ->title('Permission denied') + ->danger() + ->send(); + + return; + } + + /** @var BaselineProfile $profile */ + $profile = $this->getOwnerRecord(); + + $this->auditAssignment($profile, $record, $user, 'removed'); + + $record->delete(); + + Notification::make() + ->title('Assignment removed') + ->success() + ->send(); + }); + } + + /** + * @return array + */ + private function getAvailableTenantOptions(): array + { + /** @var BaselineProfile $profile */ + $profile = $this->getOwnerRecord(); + + $assignedTenantIds = BaselineTenantAssignment::query() + ->where('workspace_id', $profile->workspace_id) + ->pluck('tenant_id') + ->all(); + + $query = Tenant::query() + ->where('workspace_id', $profile->workspace_id) + ->orderBy('display_name'); + + if (! empty($assignedTenantIds)) { + $query->whereNotIn('id', $assignedTenantIds); + } + + return $query->pluck('display_name', 'id')->all(); + } + + private function auditAssignment( + BaselineProfile $profile, + BaselineTenantAssignment $assignment, + User $user, + string $action, + ): void { + $workspace = Workspace::query()->find($profile->workspace_id); + + if (! $workspace instanceof Workspace) { + return; + } + + $tenant = Tenant::query()->find($assignment->tenant_id); + + $auditLogger = app(WorkspaceAuditLogger::class); + + $auditLogger->log( + workspace: $workspace, + action: 'baseline.assignment.' . $action, + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + 'tenant_id' => (int) $assignment->tenant_id, + 'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—', + ], + actor: $user, + resourceType: 'baseline_profile', + resourceId: (string) $profile->getKey(), + ); + } + + private function hasManageCapability(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + 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_MANAGE); } } diff --git a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php new file mode 100644 index 0000000..e2113a3 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php @@ -0,0 +1,74 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'hasAssignment' => false, + 'profileName' => null, + 'findingsCount' => 0, + 'highCount' => 0, + 'landingUrl' => null, + ]; + } + + $assignment = BaselineTenantAssignment::query() + ->where('tenant_id', $tenant->getKey()) + ->with('baselineProfile') + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) { + return [ + 'hasAssignment' => false, + 'profileName' => null, + 'findingsCount' => 0, + 'highCount' => 0, + 'landingUrl' => null, + ]; + } + + $profile = $assignment->baselineProfile; + $scopeKey = 'baseline_profile:' . $profile->getKey(); + + $findingsQuery = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->where('status', Finding::STATUS_NEW); + + $findingsCount = (int) (clone $findingsQuery)->count(); + $highCount = (int) (clone $findingsQuery) + ->where('severity', Finding::SEVERITY_HIGH) + ->count(); + + return [ + 'hasAssignment' => true, + 'profileName' => (string) $profile->name, + 'findingsCount' => $findingsCount, + 'highCount' => $highCount, + 'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant), + ]; + } +} diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php new file mode 100644 index 0000000..2d0cb1b --- /dev/null +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -0,0 +1,394 @@ +operationRun = $run; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + DriftHasher $driftHasher, + BaselineSnapshotIdentity $snapshotIdentity, + AuditLogger $auditLogger, + OperationRunService $operationRunService, + ): void { + if (! $this->operationRun instanceof OperationRun) { + $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.')); + + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $profileId = (int) ($context['baseline_profile_id'] ?? 0); + $snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0); + + $profile = BaselineProfile::query()->find($profileId); + + if (! $profile instanceof BaselineProfile) { + throw new RuntimeException("BaselineProfile #{$profileId} not found."); + } + + $tenant = Tenant::query()->find($this->operationRun->tenant_id); + + if (! $tenant instanceof Tenant) { + throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found."); + } + + $initiator = $this->operationRun->user_id + ? User::query()->find($this->operationRun->user_id) + : null; + + $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); + $scopeKey = 'baseline_profile:' . $profile->getKey(); + + $this->auditStarted($auditLogger, $tenant, $profile, $initiator); + + $baselineItems = $this->loadBaselineItems($snapshotId); + $currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity); + + $driftResults = $this->computeDrift($baselineItems, $currentItems); + + $upsertedCount = $this->upsertFindings( + $driftHasher, + $tenant, + $profile, + $scopeKey, + $driftResults, + ); + + $severityBreakdown = $this->countBySeverity($driftResults); + + $summaryCounts = [ + 'total' => count($driftResults), + 'processed' => count($driftResults), + 'succeeded' => $upsertedCount, + 'failed' => count($driftResults) - $upsertedCount, + 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, + 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, + 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, + ]; + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: $summaryCounts, + ); + + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['result'] = [ + 'findings_total' => count($driftResults), + 'findings_upserted' => $upsertedCount, + 'severity_breakdown' => $severityBreakdown, + ]; + $this->operationRun->update(['context' => $updatedContext]); + + $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); + } + + /** + * Load baseline snapshot items keyed by "policy_type|subject_external_id". + * + * @return array}> + */ + private function loadBaselineItems(int $snapshotId): array + { + $items = []; + + BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', $snapshotId) + ->orderBy('id') + ->chunk(500, function ($snapshotItems) use (&$items): void { + foreach ($snapshotItems as $item) { + $key = $item->policy_type . '|' . $item->subject_external_id; + $items[$key] = [ + 'subject_type' => (string) $item->subject_type, + 'subject_external_id' => (string) $item->subject_external_id, + 'policy_type' => (string) $item->policy_type, + 'baseline_hash' => (string) $item->baseline_hash, + 'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [], + ]; + } + }); + + return $items; + } + + /** + * Load current inventory items keyed by "policy_type|external_id". + * + * @return array}> + */ + private function loadCurrentInventory( + Tenant $tenant, + BaselineScope $scope, + BaselineSnapshotIdentity $snapshotIdentity, + ): array { + $query = InventoryItem::query() + ->where('tenant_id', $tenant->getKey()); + + if (! $scope->isEmpty()) { + $query->whereIn('policy_type', $scope->policyTypes); + } + + $items = []; + + $query->orderBy('policy_type') + ->orderBy('external_id') + ->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void { + foreach ($inventoryItems as $inventoryItem) { + $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; + $currentHash = $snapshotIdentity->hashItemContent($metaJsonb); + + $key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id; + $items[$key] = [ + 'subject_external_id' => (string) $inventoryItem->external_id, + 'policy_type' => (string) $inventoryItem->policy_type, + 'current_hash' => $currentHash, + 'meta_jsonb' => [ + 'display_name' => $inventoryItem->display_name, + 'category' => $inventoryItem->category, + 'platform' => $inventoryItem->platform, + ], + ]; + } + }); + + return $items; + } + + /** + * Compare baseline items vs current inventory and produce drift results. + * + * @param array}> $baselineItems + * @param array}> $currentItems + * @return array}> + */ + private function computeDrift(array $baselineItems, array $currentItems): array + { + $drift = []; + + foreach ($baselineItems as $key => $baselineItem) { + if (! array_key_exists($key, $currentItems)) { + $drift[] = [ + 'change_type' => 'missing_policy', + 'severity' => Finding::SEVERITY_HIGH, + 'subject_type' => $baselineItem['subject_type'], + 'subject_external_id' => $baselineItem['subject_external_id'], + 'policy_type' => $baselineItem['policy_type'], + 'baseline_hash' => $baselineItem['baseline_hash'], + 'current_hash' => '', + 'evidence' => [ + 'change_type' => 'missing_policy', + 'policy_type' => $baselineItem['policy_type'], + 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, + ], + ]; + + continue; + } + + $currentItem = $currentItems[$key]; + + if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) { + $drift[] = [ + 'change_type' => 'different_version', + 'severity' => Finding::SEVERITY_MEDIUM, + 'subject_type' => $baselineItem['subject_type'], + 'subject_external_id' => $baselineItem['subject_external_id'], + 'policy_type' => $baselineItem['policy_type'], + 'baseline_hash' => $baselineItem['baseline_hash'], + 'current_hash' => $currentItem['current_hash'], + 'evidence' => [ + 'change_type' => 'different_version', + 'policy_type' => $baselineItem['policy_type'], + 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, + 'baseline_hash' => $baselineItem['baseline_hash'], + 'current_hash' => $currentItem['current_hash'], + ], + ]; + } + } + + foreach ($currentItems as $key => $currentItem) { + if (! array_key_exists($key, $baselineItems)) { + $drift[] = [ + 'change_type' => 'unexpected_policy', + 'severity' => Finding::SEVERITY_LOW, + 'subject_type' => 'policy', + 'subject_external_id' => $currentItem['subject_external_id'], + 'policy_type' => $currentItem['policy_type'], + 'baseline_hash' => '', + 'current_hash' => $currentItem['current_hash'], + 'evidence' => [ + 'change_type' => 'unexpected_policy', + 'policy_type' => $currentItem['policy_type'], + 'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null, + ], + ]; + } + } + + return $drift; + } + + /** + * Upsert drift findings using stable fingerprints. + * + * @param array}> $driftResults + */ + private function upsertFindings( + DriftHasher $driftHasher, + Tenant $tenant, + BaselineProfile $profile, + string $scopeKey, + array $driftResults, + ): int { + $upsertedCount = 0; + $tenantId = (int) $tenant->getKey(); + + foreach ($driftResults as $driftItem) { + $fingerprint = $driftHasher->fingerprint( + tenantId: $tenantId, + scopeKey: $scopeKey, + subjectType: $driftItem['subject_type'], + subjectExternalId: $driftItem['subject_external_id'], + changeType: $driftItem['change_type'], + baselineHash: $driftItem['baseline_hash'], + currentHash: $driftItem['current_hash'], + ); + + Finding::query()->updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'fingerprint' => $fingerprint, + ], + [ + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'source' => 'baseline.compare', + 'scope_key' => $scopeKey, + 'subject_type' => $driftItem['subject_type'], + 'subject_external_id' => $driftItem['subject_external_id'], + 'severity' => $driftItem['severity'], + 'status' => Finding::STATUS_NEW, + 'evidence_jsonb' => $driftItem['evidence'], + 'baseline_operation_run_id' => null, + 'current_operation_run_id' => (int) $this->operationRun->getKey(), + ], + ); + + $upsertedCount++; + } + + return $upsertedCount; + } + + /** + * @param array $driftResults + * @return array + */ + private function countBySeverity(array $driftResults): array + { + $counts = []; + + foreach ($driftResults as $item) { + $severity = $item['severity']; + $counts[$severity] = ($counts[$severity] ?? 0) + 1; + } + + return $counts; + } + + private function auditStarted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + ?User $initiator, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.compare.started', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'baseline_profile', + resourceId: (string) $profile->getKey(), + ); + } + + private function auditCompleted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + ?User $initiator, + array $summaryCounts, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.compare.completed', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + 'findings_total' => $summaryCounts['total'] ?? 0, + 'high' => $summaryCounts['high'] ?? 0, + 'medium' => $summaryCounts['medium'] ?? 0, + 'low' => $summaryCounts['low'] ?? 0, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + } +} diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php new file mode 100644 index 0000000..a4cc940 --- /dev/null +++ b/app/Services/Baselines/BaselineCompareService.php @@ -0,0 +1,97 @@ +where('workspace_id', $tenant->workspace_id) + ->where('tenant_id', $tenant->getKey()) + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment) { + return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT]; + } + + $profile = BaselineProfile::query()->find($assignment->baseline_profile_id); + + if (! $profile instanceof BaselineProfile) { + return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE]; + } + + $precondition = $this->validatePreconditions($profile); + + if ($precondition !== null) { + return ['ok' => false, 'reason_code' => $precondition]; + } + + $snapshotId = (int) $profile->active_snapshot_id; + + $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); + + $context = [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => $snapshotId, + 'effective_scope' => $effectiveScope->toJsonb(), + ]; + + $run = $this->runs->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: [ + 'baseline_profile_id' => (int) $profile->getKey(), + ], + context: $context, + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + CompareBaselineToTenantJob::dispatch($run); + } + + return ['ok' => true, 'run' => $run]; + } + + private function validatePreconditions(BaselineProfile $profile): ?string + { + if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { + return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE; + } + + if ($profile->active_snapshot_id === null) { + return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT; + } + + return null; + } +} diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php new file mode 100644 index 0000000..d8bb3f1 --- /dev/null +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -0,0 +1,93 @@ + + +
+
+ Compare the current tenant state against the assigned baseline profile to detect drift. +
+ + @if (filled($profileName)) +
+ Baseline Profile: + {{ $profileName }} + @if ($snapshotId) + + Snapshot #{{ $snapshotId }} + + @endif +
+ @endif + + @if ($state === 'no_tenant') + No tenant selected +
{{ $message }}
+ @elseif ($state === 'no_assignment') + No baseline assigned +
{{ $message }}
+ @elseif ($state === 'no_snapshot') + No snapshot available +
{{ $message }}
+ @elseif ($state === 'comparing') +
+ Comparing… + +
+
{{ $message }}
+ + @if ($this->getRunUrl()) + + View operation run + + @endif + @elseif ($state === 'idle') + Ready to compare +
{{ $message }}
+ @elseif ($state === 'ready') + @if ($findingsCount !== null && $findingsCount > 0) +
+ + {{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }} + + + @if (($severityCounts['high'] ?? 0) > 0) + + {{ $severityCounts['high'] }} high + + @endif + + @if (($severityCounts['medium'] ?? 0) > 0) + + {{ $severityCounts['medium'] }} medium + + @endif + + @if (($severityCounts['low'] ?? 0) > 0) + + {{ $severityCounts['low'] }} low + + @endif +
+ + @if ($this->getFindingsUrl()) + + View findings + + @endif + @else + + No drift detected + + + @if (filled($message)) +
{{ $message }}
+ @endif + @endif + + @if ($this->getRunUrl()) + + View last run + + @endif + @endif +
+
+
diff --git a/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php new file mode 100644 index 0000000..03fa817 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php @@ -0,0 +1,41 @@ +
+
+
+ Soll vs Ist +
+ + @if (! $hasAssignment) +
+ No baseline profile assigned yet. +
+ @else +
+ Baseline: {{ $profileName }} +
+ + @if ($findingsCount > 0) +
+ + {{ $findingsCount }} open {{ Str::plural('finding', $findingsCount) }} + + + @if ($highCount > 0) + + {{ $highCount }} high + + @endif +
+ @else + + No open drift + + @endif + + @if ($landingUrl) + + Go to Soll vs Ist + + @endif + @endif +
+
diff --git a/tests/Feature/Baselines/BaselineAssignmentTest.php b/tests/Feature/Baselines/BaselineAssignmentTest.php new file mode 100644 index 0000000..64df738 --- /dev/null +++ b/tests/Feature/Baselines/BaselineAssignmentTest.php @@ -0,0 +1,126 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + expect($assignment)->toBeInstanceOf(BaselineTenantAssignment::class); + expect($assignment->workspace_id)->toBe((int) $tenant->workspace_id); + expect($assignment->tenant_id)->toBe((int) $tenant->getKey()); + expect($assignment->baseline_profile_id)->toBe((int) $profile->getKey()); + expect($assignment->assigned_by_user_id)->toBe((int) $user->getKey()); +}); + +it('prevents duplicate assignments for the same workspace+tenant', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile1 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + $profile2 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile1->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + // Attempting to assign the same tenant in the same workspace should fail + // due to the unique constraint on (workspace_id, tenant_id) + expect(fn () => BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile2->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]))->toThrow(\Illuminate\Database\QueryException::class); +}); + +it('allows the same tenant to be assigned in different workspaces', function () { + [$user1, $tenant1] = createUserWithTenant(role: 'owner'); + [$user2, $tenant2] = createUserWithTenant(role: 'owner'); + + $profile1 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant1->workspace_id, + ]); + $profile2 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant2->workspace_id, + ]); + + $a1 = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant1->workspace_id, + 'tenant_id' => (int) $tenant1->getKey(), + 'baseline_profile_id' => (int) $profile1->getKey(), + ]); + $a2 = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant2->workspace_id, + 'tenant_id' => (int) $tenant2->getKey(), + 'baseline_profile_id' => (int) $profile2->getKey(), + ]); + + expect($a1->exists)->toBeTrue(); + expect($a2->exists)->toBeTrue(); +}); + +it('deletes an assignment without deleting related models', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + $assignmentId = $assignment->getKey(); + $assignment->delete(); + + expect(BaselineTenantAssignment::query()->find($assignmentId))->toBeNull(); + expect(BaselineProfile::query()->find($profile->getKey()))->not->toBeNull(); + expect(Tenant::query()->find($tenant->getKey()))->not->toBeNull(); +}); + +it('loads the baseline profile relationship from assignment', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'name' => 'Test Profile', + ]); + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $loaded = $assignment->baselineProfile; + + expect($loaded)->not->toBeNull(); + expect($loaded->name)->toBe('Test Profile'); +}); diff --git a/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/tests/Feature/Baselines/BaselineCompareFindingsTest.php new file mode 100644 index 0000000..bbf8704 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -0,0 +1,414 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // Baseline has policyA and policyB + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-a-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'content-a'), + 'meta_jsonb' => ['display_name' => 'Policy A'], + ]); + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-b-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'content-b'), + 'meta_jsonb' => ['display_name' => 'Policy B'], + ]); + + // Tenant has policyA (different content) and policyC (unexpected) + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-a-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['different_content' => true], + 'display_name' => 'Policy A modified', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-c-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['new_policy' => true], + 'display_name' => 'Policy C unexpected', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + 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']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $scopeKey = 'baseline_profile:' . $profile->getKey(); + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->get(); + + // policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings + expect($findings->count())->toBe(3); + + $severities = $findings->pluck('severity')->sort()->values()->all(); + expect($severities)->toContain(Finding::SEVERITY_HIGH); + expect($severities)->toContain(Finding::SEVERITY_MEDIUM); + expect($severities)->toContain(Finding::SEVERITY_LOW); +}); + +it('produces idempotent fingerprints so re-running compare updates existing findings', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-content'), + 'meta_jsonb' => ['display_name' => 'Policy X'], + ]); + + // Tenant does NOT have policy-x → missing_policy finding + $opService = app(OperationRunService::class); + + // First run + $run1 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + 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']], + ], + initiator: $user, + ); + + $job1 = new CompareBaselineToTenantJob($run1); + $job1->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $scopeKey = 'baseline_profile:' . $profile->getKey(); + $countAfterFirst = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->count(); + + expect($countAfterFirst)->toBe(1); + + // Second run - new OperationRun so we can dispatch again + // Mark first run as completed so ensureRunWithIdentity creates a new one + $run1->update(['status' => 'completed', 'completed_at' => now()]); + + $run2 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + 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']], + ], + initiator: $user, + ); + + $job2 = new CompareBaselineToTenantJob($run2); + $job2->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $countAfterSecond = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->count(); + + // Same fingerprint → same finding updated, not duplicated + expect($countAfterSecond)->toBe(1); +}); + +it('creates zero findings when baseline matches tenant inventory exactly', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // Baseline item + $metaContent = ['policy_key' => 'value123']; + $driftHasher = app(DriftHasher::class); + $contentHash = $driftHasher->hashNormalized($metaContent); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'matching-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $contentHash, + 'meta_jsonb' => ['display_name' => 'Matching Policy'], + ]); + + // Tenant inventory with same content → same hash + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'matching-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => $metaContent, + 'display_name' => 'Matching Policy', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + 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']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? -1))->toBe(0); + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->count(); + + expect($findings)->toBe(0); +}); + +// --- T042: Summary counts severity breakdown tests --- + +it('writes severity breakdown in summary_counts', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // 2 baseline items: one will be missing (high), one will be different (medium) + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'missing-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'missing-content'), + 'meta_jsonb' => ['display_name' => 'Missing Policy'], + ]); + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'changed-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'original-content'), + 'meta_jsonb' => ['display_name' => 'Changed Policy'], + ]); + + // Tenant only has changed-uuid with different content + extra-uuid (unexpected) + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'changed-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['modified_content' => true], + 'display_name' => 'Changed Policy', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'extra-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['extra_content' => true], + 'display_name' => 'Extra Policy', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + 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']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + + expect((int) ($counts['total'] ?? -1))->toBe(3); + expect((int) ($counts['high'] ?? -1))->toBe(1); // missing-uuid + expect((int) ($counts['medium'] ?? -1))->toBe(1); // changed-uuid + expect((int) ($counts['low'] ?? -1))->toBe(1); // extra-uuid +}); + +it('writes result context with findings breakdown', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // One missing policy + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'gone-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'gone-content'), + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + 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']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + $context = is_array($run->context) ? $run->context : []; + $result = $context['result'] ?? []; + + expect($result)->toHaveKey('findings_total'); + expect($result)->toHaveKey('findings_upserted'); + expect($result)->toHaveKey('severity_breakdown'); + expect((int) $result['findings_total'])->toBe(1); + expect((int) $result['findings_upserted'])->toBe(1); +}); diff --git a/tests/Feature/Baselines/BaselineComparePreconditionsTest.php b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php new file mode 100644 index 0000000..b8df465 --- /dev/null +++ b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php @@ -0,0 +1,178 @@ +startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); +}); + +it('rejects compare when assigned profile is in draft status', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'status' => BaselineProfile::STATUS_DRAFT, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); +}); + +it('rejects compare when assigned profile is archived [EC-001]', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->archived()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); +}); + +it('rejects compare when profile has no active snapshot', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'active_snapshot_id' => null, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); +}); + +it('enqueues compare successfully when all preconditions are met', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeTrue(); + expect($result)->toHaveKey('run'); + + /** @var OperationRun $run */ + $run = $result['run']; + expect($run->type)->toBe('baseline_compare'); + expect($run->status)->toBe('queued'); + + $context = is_array($run->context) ? $run->context : []; + expect($context['baseline_profile_id'])->toBe((int) $profile->getKey()); + expect($context['baseline_snapshot_id'])->toBe((int) $snapshot->getKey()); + + Queue::assertPushed(CompareBaselineToTenantJob::class); +}); + +// --- EC-004: Concurrent compare reuses active run --- + +it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + + $result1 = $service->startCompare($tenant, $user); + $result2 = $service->startCompare($tenant, $user); + + expect($result1['ok'])->toBeTrue(); + expect($result2['ok'])->toBeTrue(); + expect($result1['run']->getKey())->toBe($result2['run']->getKey()); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1); +});