TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php

395 lines
15 KiB
PHP

<?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(),
);
}
}