Implements Spec 117 (Golden Master Baseline Drift Engine): - Adds provider-chain resolver for current state hashes (content evidence via PolicyVersion, meta evidence via inventory) - Updates baseline capture + compare jobs to use resolver and persist provenance + fidelity - Adds evidence_fidelity column/index + Filament UI badge/filter/provenance display for findings - Adds performance guard test + integration tests for drift, fidelity semantics, provenance, filter behavior - UX fix: Policies list shows "Sync from Intune" header action only when records exist; empty-state CTA remains and is functional Tests: - `vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicySyncCtaPlacementTest.php` - `vendor/bin/sail artisan test --compact --filter=Baseline` Checklist: - specs/117-baseline-drift-engine/checklists/requirements.md ✓ Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #142
345 lines
12 KiB
PHP
345 lines
12 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\Baselines\CurrentStateHashResolver;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Services\Baselines\InventoryMetaContract;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\Baselines\BaselineProfileStatus;
|
|
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,
|
|
InventoryMetaContract $metaContract,
|
|
AuditLogger $auditLogger,
|
|
OperationRunService $operationRunService,
|
|
?CurrentStateHashResolver $hashResolver = null,
|
|
): void {
|
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
|
|
|
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, $metaContract, $hashResolver);
|
|
|
|
$identityHash = $identity->computeIdentity($snapshotItems);
|
|
|
|
$snapshot = $this->findOrCreateSnapshot(
|
|
$profile,
|
|
$identityHash,
|
|
$snapshotItems,
|
|
);
|
|
|
|
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
|
|
|
if ($profile->status === BaselineProfileStatus::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,
|
|
InventoryMetaContract $metaContract,
|
|
CurrentStateHashResolver $hashResolver,
|
|
): array {
|
|
$query = InventoryItem::query()
|
|
->where('tenant_id', $sourceTenant->getKey());
|
|
|
|
$query->whereIn('policy_type', $scope->allTypes());
|
|
|
|
/**
|
|
* @var array<string, array{
|
|
* subject_external_id: string,
|
|
* policy_type: string,
|
|
* display_name: ?string,
|
|
* category: ?string,
|
|
* platform: ?string,
|
|
* meta_contract: array<string, mixed>
|
|
* }>
|
|
*/
|
|
$inventoryByKey = [];
|
|
|
|
$query->orderBy('policy_type')
|
|
->orderBy('external_id')
|
|
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, $metaContract): void {
|
|
foreach ($inventoryItems as $inventoryItem) {
|
|
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
|
$contract = $metaContract->build(
|
|
policyType: (string) $inventoryItem->policy_type,
|
|
subjectExternalId: (string) $inventoryItem->external_id,
|
|
metaJsonb: $metaJsonb,
|
|
);
|
|
|
|
$key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id;
|
|
|
|
$inventoryByKey[$key] = [
|
|
'subject_external_id' => (string) $inventoryItem->external_id,
|
|
'policy_type' => (string) $inventoryItem->policy_type,
|
|
'display_name' => is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null,
|
|
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
|
|
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
|
|
'meta_contract' => $contract,
|
|
];
|
|
}
|
|
});
|
|
|
|
$subjects = array_values(array_map(
|
|
static fn (array $item): array => [
|
|
'policy_type' => (string) $item['policy_type'],
|
|
'subject_external_id' => (string) $item['subject_external_id'],
|
|
],
|
|
$inventoryByKey,
|
|
));
|
|
|
|
$resolvedEvidence = $hashResolver->resolveForSubjects(
|
|
tenant: $sourceTenant,
|
|
subjects: $subjects,
|
|
since: null,
|
|
latestInventorySyncRunId: null,
|
|
);
|
|
|
|
$items = [];
|
|
|
|
foreach ($inventoryByKey as $key => $inventoryItem) {
|
|
$evidence = $resolvedEvidence[$key] ?? null;
|
|
|
|
if (! $evidence instanceof ResolvedEvidence) {
|
|
continue;
|
|
}
|
|
|
|
$items[] = [
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => (string) $inventoryItem['subject_external_id'],
|
|
'policy_type' => (string) $inventoryItem['policy_type'],
|
|
'baseline_hash' => $evidence->hash,
|
|
'meta_jsonb' => [
|
|
'display_name' => $inventoryItem['display_name'],
|
|
'category' => $inventoryItem['category'],
|
|
'platform' => $inventoryItem['platform'],
|
|
'meta_contract' => $inventoryItem['meta_contract'],
|
|
'evidence' => $evidence->provenance(),
|
|
],
|
|
];
|
|
}
|
|
|
|
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(),
|
|
);
|
|
}
|
|
}
|