TenantAtlas/app/Jobs/CaptureBaselineSnapshotJob.php

286 lines
9.8 KiB
PHP

<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity;
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 CaptureBaselineSnapshotJob 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(
BaselineSnapshotIdentity $identity,
AuditLogger $auditLogger,
OperationRunService $operationRunService,
): void {
if (! $this->operationRun instanceof OperationRun) {
$this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.'));
return;
}
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
$sourceTenantId = (int) ($context['source_tenant_id'] ?? 0);
$profile = BaselineProfile::query()->find($profileId);
if (! $profile instanceof BaselineProfile) {
throw new RuntimeException("BaselineProfile #{$profileId} not found.");
}
$sourceTenant = Tenant::query()->find($sourceTenantId);
if (! $sourceTenant instanceof Tenant) {
throw new RuntimeException("Source Tenant #{$sourceTenantId} not found.");
}
$initiator = $this->operationRun->user_id
? User::query()->find($this->operationRun->user_id)
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity);
$identityHash = $identity->computeIdentity($snapshotItems);
$snapshot = $this->findOrCreateSnapshot(
$profile,
$identityHash,
$snapshotItems,
);
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
if ($profile->status === BaselineProfile::STATUS_ACTIVE) {
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
}
$summaryCounts = [
'total' => count($snapshotItems),
'processed' => count($snapshotItems),
'succeeded' => count($snapshotItems),
'failed' => 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'] = [
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => count($snapshotItems),
];
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems);
}
/**
* @return array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
private function collectSnapshotItems(
Tenant $sourceTenant,
BaselineScope $scope,
BaselineSnapshotIdentity $identity,
): array {
$query = InventoryItem::query()
->where('tenant_id', $sourceTenant->getKey());
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
}
$items = [];
$query->orderBy('policy_type')
->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$items, $identity): void {
foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$baselineHash = $identity->hashItemContent($metaJsonb);
$items[] = [
'subject_type' => 'policy',
'subject_external_id' => (string) $inventoryItem->external_id,
'policy_type' => (string) $inventoryItem->policy_type,
'baseline_hash' => $baselineHash,
'meta_jsonb' => [
'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform,
],
];
}
});
return $items;
}
/**
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $snapshotItems
*/
private function findOrCreateSnapshot(
BaselineProfile $profile,
string $identityHash,
array $snapshotItems,
): BaselineSnapshot {
$existing = BaselineSnapshot::query()
->where('workspace_id', $profile->workspace_id)
->where('baseline_profile_id', $profile->getKey())
->where('snapshot_identity_hash', $identityHash)
->first();
if ($existing instanceof BaselineSnapshot) {
return $existing;
}
$snapshot = BaselineSnapshot::create([
'workspace_id' => (int) $profile->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'snapshot_identity_hash' => $identityHash,
'captured_at' => now(),
'summary_jsonb' => [
'total_items' => count($snapshotItems),
'policy_type_counts' => $this->countByPolicyType($snapshotItems),
],
]);
foreach (array_chunk($snapshotItems, 100) as $chunk) {
$rows = array_map(
fn (array $item): array => [
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => $item['subject_type'],
'subject_external_id' => $item['subject_external_id'],
'policy_type' => $item['policy_type'],
'baseline_hash' => $item['baseline_hash'],
'meta_jsonb' => json_encode($item['meta_jsonb']),
'created_at' => now(),
'updated_at' => now(),
],
$chunk,
);
BaselineSnapshotItem::insert($rows);
}
return $snapshot;
}
/**
* @param array<int, array{policy_type: string}> $items
* @return array<string, int>
*/
private function countByPolicyType(array $items): array
{
$counts = [];
foreach ($items as $item) {
$type = (string) $item['policy_type'];
$counts[$type] = ($counts[$type] ?? 0) + 1;
}
ksort($counts);
return $counts;
}
private function auditStarted(
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
): void {
$auditLogger->log(
tenant: $tenant,
action: 'baseline.capture.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,
BaselineSnapshot $snapshot,
?User $initiator,
array $snapshotItems,
): void {
$auditLogger->log(
tenant: $tenant,
action: 'baseline.capture.completed',
context: [
'metadata' => [
'operation_run_id' => (int) $this->operationRun->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
'items_captured' => count($snapshotItems),
'was_new_snapshot' => $snapshot->wasRecentlyCreated,
],
],
actorId: $initiator?->id,
actorEmail: $initiator?->email,
actorName: $initiator?->name,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
}