Baseline governance UX polish + view Infolist #123

Merged
ahmido merged 10 commits from 101-golden-master-baseline-governance-v1 into dev 2026-02-19 23:56:11 +00:00
11 changed files with 1894 additions and 1 deletions
Showing only changes of commit 8402f197e5 - Show all commits

View File

@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class BaselineCompareLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Soll vs Ist';
protected static ?int $navigationSort = 30;
protected string $view = 'filament.pages.baseline-compare-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $profileName = null;
public ?int $profileId = null;
public ?int $snapshotId = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
/** @var array<string, int>|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<Action>
*/
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);
}
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings; use App\Filament\Widgets\Dashboard\RecentDriftFindings;
@ -31,6 +32,7 @@ public function getWidgets(): array
return [ return [
DashboardKpis::class, DashboardKpis::class,
NeedsAttention::class, NeedsAttention::class,
BaselineCompareNow::class,
RecentDriftFindings::class, RecentDriftFindings::class,
RecentOperations::class, RecentOperations::class,
]; ];

View File

@ -4,10 +4,21 @@
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers; 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\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; 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\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -43,7 +54,193 @@ public function table(Table $table): Table
->dateTime() ->dateTime()
->sortable(), ->sortable(),
]) ])
->headerActions([
$this->assignTenantAction(),
])
->actions([
$this->removeAssignmentAction(),
])
->emptyStateHeading('No tenants assigned') ->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<int, string>
*/
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);
} }
} }

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class BaselineCompareNow extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.baseline-compare-now';
/**
* @return array<string, mixed>
*/
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),
];
}
}

View File

@ -0,0 +1,394 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class CompareBaselineToTenantJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public OperationRun $run,
) {
$this->operationRun = $run;
}
/**
* @return array<int, object>
*/
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<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
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<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}>
*/
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<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @param array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}> $currentItems
* @return array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>
*/
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<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $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<int, array{severity: string}> $driftResults
* @return array<string, int>
*/
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(),
);
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
final class BaselineCompareService
{
public function __construct(
private readonly OperationRunService $runs,
) {}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
*/
public function startCompare(
Tenant $tenant,
User $initiator,
): array {
$assignment = BaselineTenantAssignment::query()
->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;
}
}

View File

@ -0,0 +1,93 @@
<x-filament::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Compare the current tenant state against the assigned baseline profile to detect drift.
</div>
@if (filled($profileName))
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-gray-950 dark:text-white">Baseline Profile:</span>
<span class="text-gray-700 dark:text-gray-200">{{ $profileName }}</span>
@if ($snapshotId)
<x-filament::badge color="success" size="sm">
Snapshot #{{ $snapshotId }}
</x-filament::badge>
@endif
</div>
@endif
@if ($state === 'no_tenant')
<x-filament::badge color="gray">No tenant selected</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'no_assignment')
<x-filament::badge color="gray">No baseline assigned</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'no_snapshot')
<x-filament::badge color="warning">No snapshot available</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'comparing')
<div class="flex items-center gap-2">
<x-filament::badge color="info">Comparing…</x-filament::badge>
<x-filament::loading-indicator class="h-4 w-4" />
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View operation run
</x-filament::link>
@endif
@elseif ($state === 'idle')
<x-filament::badge color="gray">Ready to compare</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'ready')
@if ($findingsCount !== null && $findingsCount > 0)
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if (($severityCounts['high'] ?? 0) > 0)
<x-filament::badge color="danger" size="sm">
{{ $severityCounts['high'] }} high
</x-filament::badge>
@endif
@if (($severityCounts['medium'] ?? 0) > 0)
<x-filament::badge color="warning" size="sm">
{{ $severityCounts['medium'] }} medium
</x-filament::badge>
@endif
@if (($severityCounts['low'] ?? 0) > 0)
<x-filament::badge color="gray" size="sm">
{{ $severityCounts['low'] }} low
</x-filament::badge>
@endif
</div>
@if ($this->getFindingsUrl())
<x-filament::link :href="$this->getFindingsUrl()" size="sm">
View findings
</x-filament::link>
@endif
@else
<x-filament::badge color="success" size="sm">
No drift detected
</x-filament::badge>
@if (filled($message))
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@endif
@endif
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View last run
</x-filament::link>
@endif
@endif
</div>
</x-filament::section>
</x-filament::page>

View File

@ -0,0 +1,41 @@
<div class="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="flex flex-col gap-3">
<div class="text-base font-semibold text-gray-950 dark:text-white">
Soll vs Ist
</div>
@if (! $hasAssignment)
<div class="text-sm text-gray-500 dark:text-gray-400">
No baseline profile assigned yet.
</div>
@else
<div class="text-sm text-gray-600 dark:text-gray-300">
Baseline: <span class="font-medium">{{ $profileName }}</span>
</div>
@if ($findingsCount > 0)
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} open {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if ($highCount > 0)
<x-filament::badge color="danger" size="sm">
{{ $highCount }} high
</x-filament::badge>
@endif
</div>
@else
<x-filament::badge color="success" size="sm">
No open drift
</x-filament::badge>
@endif
@if ($landingUrl)
<x-filament::link :href="$landingUrl" size="sm">
Go to Soll vs Ist
</x-filament::link>
@endif
@endif
</div>
</div>

View File

@ -0,0 +1,126 @@
<?php
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
// --- T039: Assignment CRUD tests (RBAC + uniqueness) ---
it('creates a tenant assignment with workspace context', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->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');
});

View File

@ -0,0 +1,414 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
// --- T041: Compare idempotent finding fingerprint tests ---
it('creates drift findings when baseline and tenant inventory differ', 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 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);
});

View File

@ -0,0 +1,178 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineReasonCodes;
use Illuminate\Support\Facades\Queue;
// --- T040: Compare precondition 422 tests ---
it('rejects compare when tenant has no baseline assignment', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$service = app(BaselineCompareService::class);
$result = $service->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);
});