## Summary - add explicit BaselineSnapshot lifecycle truth with conservative backfill and a shared truth resolver - block baseline compare from building, incomplete, or superseded snapshots and align workspace/tenant UI truth surfaces with effective snapshot state - surface artifact truth separately from operation outcome across baseline profile, snapshot, compare, and operation run pages ## Testing - integrated browser smoke test on the active feature surfaces - `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` - targeted baseline lifecycle and compare guard coverage added in Pest - `vendor/bin/sail bin pint --dirty --format agent` ## Notes - Livewire v4 compliance preserved - no panel provider registration changes were needed; Laravel 12 providers remain in `bootstrap/providers.php` - global search remains disabled for the affected baseline resources by design - destructive actions remain confirmation-gated; capture and compare actions keep their existing authorization and confirmation behavior - no new panel assets were added; existing deploy flow for `filament:assets` is unchanged Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #189
860 lines
33 KiB
PHP
860 lines
33 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\BaselineContentCapturePhase;
|
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
|
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
|
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\BaselineCaptureMode;
|
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
|
use App\Support\Baselines\BaselineProfileStatus;
|
|
use App\Support\Baselines\BaselineReasonCodes;
|
|
use App\Support\Baselines\BaselineScope;
|
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class CaptureBaselineSnapshotJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $timeout = 300;
|
|
|
|
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,
|
|
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
|
): void {
|
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
|
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::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);
|
|
|
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
|
? $profile->capture_mode
|
|
: BaselineCaptureMode::Opportunistic;
|
|
|
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
|
$rolloutGate->assertEnabled();
|
|
}
|
|
|
|
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant);
|
|
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun
|
|
? (int) $latestInventorySyncRun->getKey()
|
|
: null;
|
|
|
|
$inventoryResult = $this->collectInventorySubjects(
|
|
sourceTenant: $sourceTenant,
|
|
scope: $effectiveScope,
|
|
identity: $identity,
|
|
latestInventorySyncRunId: $latestInventorySyncRunId,
|
|
);
|
|
|
|
$subjects = $inventoryResult['subjects'];
|
|
$inventoryByKey = $inventoryResult['inventory_by_key'];
|
|
$subjectsTotal = $inventoryResult['subjects_total'];
|
|
$captureGaps = $inventoryResult['gaps'];
|
|
|
|
$this->auditStarted(
|
|
auditLogger: $auditLogger,
|
|
tenant: $sourceTenant,
|
|
profile: $profile,
|
|
initiator: $initiator,
|
|
captureMode: $captureMode,
|
|
subjectsTotal: $subjectsTotal,
|
|
effectiveScope: $effectiveScope,
|
|
inventorySyncRunId: $latestInventorySyncRunId,
|
|
);
|
|
|
|
$phaseStats = [
|
|
'requested' => 0,
|
|
'succeeded' => 0,
|
|
'skipped' => 0,
|
|
'failed' => 0,
|
|
'throttled' => 0,
|
|
];
|
|
$phaseGaps = [];
|
|
$resumeToken = null;
|
|
|
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
|
$budgets = [
|
|
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
|
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
|
'max_retries' => (int) config('tenantpilot.baselines.full_content_capture.max_retries', 3),
|
|
];
|
|
|
|
$resumeTokenIn = null;
|
|
|
|
if (is_array($context['baseline_capture'] ?? null)) {
|
|
$resumeTokenIn = $context['baseline_capture']['resume_token'] ?? null;
|
|
}
|
|
|
|
$phaseResult = $contentCapturePhase->capture(
|
|
tenant: $sourceTenant,
|
|
subjects: $subjects,
|
|
purpose: PolicyVersionCapturePurpose::BaselineCapture,
|
|
budgets: $budgets,
|
|
resumeToken: is_string($resumeTokenIn) ? $resumeTokenIn : null,
|
|
operationRunId: (int) $this->operationRun->getKey(),
|
|
baselineProfileId: (int) $profile->getKey(),
|
|
createdBy: $initiator?->email,
|
|
);
|
|
|
|
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
|
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
|
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
|
}
|
|
|
|
$resolvedEvidence = $hashResolver->resolveForSubjects(
|
|
tenant: $sourceTenant,
|
|
subjects: $subjects,
|
|
since: null,
|
|
latestInventorySyncRunId: $latestInventorySyncRunId,
|
|
);
|
|
|
|
$snapshotItems = $this->buildSnapshotItems(
|
|
inventoryByKey: $inventoryByKey,
|
|
resolvedEvidence: $resolvedEvidence,
|
|
captureMode: $captureMode,
|
|
gaps: $captureGaps,
|
|
);
|
|
|
|
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
|
$items = $normalizedItems['items'];
|
|
|
|
if (($normalizedItems['duplicates'] ?? 0) > 0) {
|
|
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
|
|
}
|
|
|
|
$identityHash = $identity->computeIdentity($items);
|
|
|
|
$gapsByReason = $this->mergeGapCounts($captureGaps, $phaseGaps);
|
|
$gapsCount = array_sum($gapsByReason);
|
|
|
|
$snapshotSummary = [
|
|
'total_items' => count($items),
|
|
'policy_type_counts' => $this->countByPolicyType($items),
|
|
'fidelity_counts' => $snapshotItems['fidelity_counts'] ?? ['content' => 0, 'meta' => 0],
|
|
'gaps' => [
|
|
'count' => $gapsCount,
|
|
'by_reason' => $gapsByReason,
|
|
],
|
|
];
|
|
|
|
$snapshotResult = $this->captureSnapshotArtifact(
|
|
$profile,
|
|
$identityHash,
|
|
$items,
|
|
$snapshotSummary,
|
|
);
|
|
|
|
$snapshot = $snapshotResult['snapshot'];
|
|
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
|
|
|
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
|
}
|
|
|
|
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
|
|
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
|
|
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
|
|
|
$summaryCounts = [
|
|
'total' => $subjectsTotal,
|
|
'processed' => $subjectsTotal,
|
|
'succeeded' => $snapshotItems['items_count'],
|
|
'failed' => max(0, $subjectsTotal - $snapshotItems['items_count']),
|
|
];
|
|
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: $outcome,
|
|
summaryCounts: $summaryCounts,
|
|
);
|
|
|
|
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
$updatedContext['baseline_capture'] = array_merge(
|
|
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
|
|
[
|
|
'subjects_total' => $subjectsTotal,
|
|
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
|
'evidence_capture' => $phaseStats,
|
|
'gaps' => [
|
|
'count' => $gapsCount,
|
|
'by_reason' => $gapsByReason,
|
|
],
|
|
'resume_token' => $resumeToken,
|
|
],
|
|
);
|
|
$updatedContext['result'] = [
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_identity_hash' => $identityHash,
|
|
'was_new_snapshot' => $wasNewSnapshot,
|
|
'items_captured' => $snapshotItems['items_count'],
|
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
];
|
|
$this->operationRun->update(['context' => $updatedContext]);
|
|
|
|
$this->auditCompleted(
|
|
auditLogger: $auditLogger,
|
|
tenant: $sourceTenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
initiator: $initiator,
|
|
captureMode: $captureMode,
|
|
subjectsTotal: $subjectsTotal,
|
|
inventorySyncRunId: $latestInventorySyncRunId,
|
|
wasNewSnapshot: $wasNewSnapshot,
|
|
evidenceCaptureStats: $phaseStats,
|
|
gaps: [
|
|
'count' => $gapsCount,
|
|
'by_reason' => $gapsByReason,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* subjects_total: int,
|
|
* subjects: list<array{policy_type: string, subject_external_id: string}>,
|
|
* inventory_by_key: array<string, array{
|
|
* tenant_subject_external_id: string,
|
|
* workspace_subject_external_id: string,
|
|
* subject_key: string,
|
|
* policy_type: string,
|
|
* identity_strategy: string,
|
|
* display_name: ?string,
|
|
* category: ?string,
|
|
* platform: ?string,
|
|
* is_built_in: ?bool,
|
|
* role_permission_count: ?int
|
|
* }>,
|
|
* gaps: array<string, int>
|
|
* }
|
|
*/
|
|
private function collectInventorySubjects(
|
|
Tenant $sourceTenant,
|
|
BaselineScope $scope,
|
|
BaselineSnapshotIdentity $identity,
|
|
?int $latestInventorySyncRunId = null,
|
|
): array {
|
|
$query = InventoryItem::query()
|
|
->where('tenant_id', $sourceTenant->getKey());
|
|
|
|
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
|
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
|
}
|
|
|
|
$query->whereIn('policy_type', $scope->allTypes());
|
|
|
|
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
|
$inventoryByKey = [];
|
|
|
|
/** @var array<string, int> $gaps */
|
|
$gaps = [];
|
|
|
|
/**
|
|
* Ensure we only include unambiguous subjects when matching by subject_key (derived from display name).
|
|
*
|
|
* When multiple inventory items share the same "policy_type|subject_key" we cannot reliably map them
|
|
* across tenants, so we treat them as an evidence gap and exclude them from the snapshot.
|
|
*
|
|
* @var array<string, true> $ambiguousKeys
|
|
*/
|
|
$ambiguousKeys = [];
|
|
|
|
/**
|
|
* @var array<string, string> $subjectKeyToInventoryKey
|
|
*/
|
|
$subjectKeyToInventoryKey = [];
|
|
|
|
$query->orderBy('policy_type')
|
|
->orderBy('external_id')
|
|
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey, $identity): void {
|
|
foreach ($inventoryItems as $inventoryItem) {
|
|
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
|
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
|
|
$policyType = (string) $inventoryItem->policy_type;
|
|
$tenantSubjectExternalId = is_string($inventoryItem->external_id) ? $inventoryItem->external_id : null;
|
|
$subjectKey = $identity->subjectKey($policyType, $displayName, $tenantSubjectExternalId);
|
|
|
|
if ($subjectKey === null) {
|
|
$gaps['missing_subject_key'] = ($gaps['missing_subject_key'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$logicalKey = $policyType.'|'.$subjectKey;
|
|
|
|
if (array_key_exists($logicalKey, $ambiguousKeys)) {
|
|
continue;
|
|
}
|
|
|
|
if (array_key_exists($logicalKey, $subjectKeyToInventoryKey)) {
|
|
$ambiguousKeys[$logicalKey] = true;
|
|
|
|
$previousKey = $subjectKeyToInventoryKey[$logicalKey];
|
|
unset($subjectKeyToInventoryKey[$logicalKey], $inventoryByKey[$previousKey]);
|
|
|
|
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$workspaceSafeId = $identity->workspaceSafeSubjectExternalId($policyType, $displayName, $tenantSubjectExternalId);
|
|
|
|
if (! is_string($workspaceSafeId) || $workspaceSafeId === '') {
|
|
$gaps['missing_subject_external_reference'] = ($gaps['missing_subject_external_reference'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$key = $policyType.'|'.(string) $inventoryItem->external_id;
|
|
$subjectKeyToInventoryKey[$logicalKey] = $key;
|
|
|
|
$inventoryByKey[$key] = [
|
|
'tenant_subject_external_id' => (string) $inventoryItem->external_id,
|
|
'workspace_subject_external_id' => $workspaceSafeId,
|
|
'subject_key' => $subjectKey,
|
|
'policy_type' => $policyType,
|
|
'identity_strategy' => InventoryPolicyTypeMeta::baselineCompareIdentityStrategy($policyType),
|
|
'display_name' => $displayName,
|
|
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
|
|
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
|
|
'is_built_in' => is_bool($metaJsonb['is_built_in'] ?? null) ? (bool) $metaJsonb['is_built_in'] : null,
|
|
'role_permission_count' => is_numeric($metaJsonb['role_permission_count'] ?? null) ? (int) $metaJsonb['role_permission_count'] : null,
|
|
];
|
|
}
|
|
});
|
|
|
|
ksort($gaps);
|
|
|
|
$subjects = array_values(array_map(
|
|
static fn (array $item): array => [
|
|
'policy_type' => (string) $item['policy_type'],
|
|
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
|
],
|
|
$inventoryByKey,
|
|
));
|
|
|
|
return [
|
|
'subjects_total' => count($subjects),
|
|
'subjects' => $subjects,
|
|
'inventory_by_key' => $inventoryByKey,
|
|
'gaps' => $gaps,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{
|
|
* tenant_subject_external_id: string,
|
|
* workspace_subject_external_id: string,
|
|
* subject_key: string,
|
|
* policy_type: string,
|
|
* identity_strategy: string,
|
|
* display_name: ?string,
|
|
* category: ?string,
|
|
* platform: ?string,
|
|
* is_built_in: ?bool,
|
|
* role_permission_count: ?int,
|
|
* }> $inventoryByKey
|
|
* @param array<string, ResolvedEvidence|null> $resolvedEvidence
|
|
* @param array<string, int> $gaps
|
|
* @return array{
|
|
* items: array<int, array{
|
|
* subject_type: string,
|
|
* subject_external_id: string,
|
|
* subject_key: string,
|
|
* policy_type: string,
|
|
* baseline_hash: string,
|
|
* meta_jsonb: array<string, mixed>
|
|
* }>,
|
|
* items_count: int,
|
|
* fidelity_counts: array{content: int, meta: int}
|
|
* }
|
|
*/
|
|
private function buildSnapshotItems(
|
|
array $inventoryByKey,
|
|
array $resolvedEvidence,
|
|
BaselineCaptureMode $captureMode,
|
|
array &$gaps,
|
|
): array {
|
|
$items = [];
|
|
$fidelityCounts = ['content' => 0, 'meta' => 0];
|
|
|
|
foreach ($inventoryByKey as $key => $inventoryItem) {
|
|
$evidence = $resolvedEvidence[$key] ?? null;
|
|
|
|
if (! $evidence instanceof ResolvedEvidence) {
|
|
$gaps['missing_evidence'] = ($gaps['missing_evidence'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ((string) $inventoryItem['policy_type'] === 'intuneRoleDefinition' && ! is_numeric($evidence->meta['policy_version_id'] ?? null)) {
|
|
$gaps['missing_role_definition_version_reference'] = ($gaps['missing_role_definition_version_reference'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$provenance = $evidence->provenance();
|
|
unset($provenance['observed_operation_run_id']);
|
|
|
|
$fidelity = (string) ($provenance['fidelity'] ?? 'meta');
|
|
$fidelityCounts[$fidelity === 'content' ? 'content' : 'meta']++;
|
|
|
|
if ($captureMode === BaselineCaptureMode::FullContent && $fidelity !== 'content') {
|
|
$gaps['meta_fallback'] = ($gaps['meta_fallback'] ?? 0) + 1;
|
|
}
|
|
|
|
$items[] = [
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => (string) $inventoryItem['workspace_subject_external_id'],
|
|
'subject_key' => (string) $inventoryItem['subject_key'],
|
|
'policy_type' => (string) $inventoryItem['policy_type'],
|
|
'baseline_hash' => $evidence->hash,
|
|
'meta_jsonb' => [
|
|
'display_name' => $inventoryItem['display_name'],
|
|
'category' => $inventoryItem['category'],
|
|
'platform' => $inventoryItem['platform'],
|
|
'evidence' => $provenance,
|
|
'identity' => [
|
|
'strategy' => $inventoryItem['identity_strategy'],
|
|
'subject_key' => $inventoryItem['subject_key'],
|
|
'workspace_subject_external_id' => $inventoryItem['workspace_subject_external_id'],
|
|
],
|
|
'version_reference' => [
|
|
'policy_version_id' => is_numeric($evidence->meta['policy_version_id'] ?? null) ? (int) $evidence->meta['policy_version_id'] : null,
|
|
'capture_purpose' => is_string($evidence->meta['capture_purpose'] ?? null) ? (string) $evidence->meta['capture_purpose'] : null,
|
|
],
|
|
'rbac' => [
|
|
'is_built_in' => $inventoryItem['is_built_in'],
|
|
'role_permission_count' => $inventoryItem['role_permission_count'],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'items' => $items,
|
|
'items_count' => count($items),
|
|
'fidelity_counts' => $fidelityCounts,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $snapshotItems
|
|
* @param array<string, mixed> $summaryJsonb
|
|
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
|
*/
|
|
private function captureSnapshotArtifact(
|
|
BaselineProfile $profile,
|
|
string $identityHash,
|
|
array $snapshotItems,
|
|
array $summaryJsonb,
|
|
): array {
|
|
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
|
|
|
|
if ($existing instanceof BaselineSnapshot) {
|
|
$this->rememberSnapshotOnRun(
|
|
snapshot: $existing,
|
|
identityHash: $identityHash,
|
|
wasNewSnapshot: false,
|
|
expectedItems: count($snapshotItems),
|
|
persistedItems: count($snapshotItems),
|
|
);
|
|
|
|
return [
|
|
'snapshot' => $existing,
|
|
'was_new_snapshot' => false,
|
|
];
|
|
}
|
|
|
|
$expectedItems = count($snapshotItems);
|
|
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
|
|
|
|
$this->rememberSnapshotOnRun(
|
|
snapshot: $snapshot,
|
|
identityHash: $identityHash,
|
|
wasNewSnapshot: true,
|
|
expectedItems: $expectedItems,
|
|
persistedItems: 0,
|
|
);
|
|
|
|
try {
|
|
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
|
|
|
|
if ($persistedItems !== $expectedItems) {
|
|
throw new RuntimeException('Baseline snapshot completion proof failed.');
|
|
}
|
|
|
|
$snapshot->markComplete($identityHash, [
|
|
'expected_identity_hash' => $identityHash,
|
|
'expected_items' => $expectedItems,
|
|
'persisted_items' => $persistedItems,
|
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
'was_empty_capture' => $expectedItems === 0,
|
|
]);
|
|
|
|
$snapshot->refresh();
|
|
|
|
$this->rememberSnapshotOnRun(
|
|
snapshot: $snapshot,
|
|
identityHash: $identityHash,
|
|
wasNewSnapshot: true,
|
|
expectedItems: $expectedItems,
|
|
persistedItems: $persistedItems,
|
|
);
|
|
|
|
return [
|
|
'snapshot' => $snapshot,
|
|
'was_new_snapshot' => true,
|
|
];
|
|
} catch (Throwable $exception) {
|
|
$persistedItems = (int) BaselineSnapshotItem::query()
|
|
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
|
->count();
|
|
|
|
$reasonCode = $exception instanceof RuntimeException
|
|
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
|
|
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
|
|
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
|
|
|
|
$snapshot->markIncomplete($reasonCode, [
|
|
'expected_identity_hash' => $identityHash,
|
|
'expected_items' => $expectedItems,
|
|
'persisted_items' => $persistedItems,
|
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
'was_empty_capture' => $expectedItems === 0,
|
|
]);
|
|
|
|
$snapshot->refresh();
|
|
|
|
$this->rememberSnapshotOnRun(
|
|
snapshot: $snapshot,
|
|
identityHash: $identityHash,
|
|
wasNewSnapshot: true,
|
|
expectedItems: $expectedItems,
|
|
persistedItems: $persistedItems,
|
|
reasonCode: $reasonCode,
|
|
);
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
|
{
|
|
$existing = BaselineSnapshot::query()
|
|
->where('workspace_id', $profile->workspace_id)
|
|
->where('baseline_profile_id', $profile->getKey())
|
|
->where('snapshot_identity_hash', $identityHash)
|
|
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
->first();
|
|
|
|
return $existing instanceof BaselineSnapshot ? $existing : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summaryJsonb
|
|
*/
|
|
private function createBuildingSnapshot(
|
|
BaselineProfile $profile,
|
|
string $identityHash,
|
|
array $summaryJsonb,
|
|
int $expectedItems,
|
|
): BaselineSnapshot {
|
|
return BaselineSnapshot::create([
|
|
'workspace_id' => (int) $profile->workspace_id,
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
|
'captured_at' => now(),
|
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
|
'summary_jsonb' => $summaryJsonb,
|
|
'completion_meta_jsonb' => [
|
|
'expected_identity_hash' => $identityHash,
|
|
'expected_items' => $expectedItems,
|
|
'persisted_items' => 0,
|
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
'was_empty_capture' => $expectedItems === 0,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $snapshotItems
|
|
*/
|
|
private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapshotItems): int
|
|
{
|
|
$persistedItems = 0;
|
|
|
|
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'],
|
|
'subject_key' => $item['subject_key'],
|
|
'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);
|
|
$persistedItems += count($rows);
|
|
}
|
|
|
|
return $persistedItems;
|
|
}
|
|
|
|
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
|
|
{
|
|
return hash(
|
|
'sha256',
|
|
implode('|', [
|
|
'building',
|
|
(string) $profile->getKey(),
|
|
(string) $this->operationRun->getKey(),
|
|
(string) microtime(true),
|
|
]),
|
|
);
|
|
}
|
|
|
|
private function rememberSnapshotOnRun(
|
|
BaselineSnapshot $snapshot,
|
|
string $identityHash,
|
|
bool $wasNewSnapshot,
|
|
int $expectedItems,
|
|
int $persistedItems,
|
|
?string $reasonCode = null,
|
|
): void {
|
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
|
|
$context['result'] = array_merge(
|
|
is_array($context['result'] ?? null) ? $context['result'] : [],
|
|
[
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_identity_hash' => $identityHash,
|
|
'was_new_snapshot' => $wasNewSnapshot,
|
|
'items_captured' => $persistedItems,
|
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
'expected_items' => $expectedItems,
|
|
],
|
|
);
|
|
|
|
if (is_string($reasonCode) && $reasonCode !== '') {
|
|
$context['reason_code'] = $reasonCode;
|
|
$context['result']['snapshot_reason_code'] = $reasonCode;
|
|
} else {
|
|
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
|
|
}
|
|
|
|
$this->operationRun->update(['context' => $context]);
|
|
$this->operationRun->refresh();
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
BaselineCaptureMode $captureMode,
|
|
int $subjectsTotal,
|
|
BaselineScope $effectiveScope,
|
|
?int $inventorySyncRunId,
|
|
): 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,
|
|
'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
|
'capture_mode' => $captureMode->value,
|
|
'inventory_sync_run_id' => $inventorySyncRunId,
|
|
'scope_types_total' => count($effectiveScope->allTypes()),
|
|
'subjects_total' => $subjectsTotal,
|
|
],
|
|
],
|
|
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,
|
|
BaselineCaptureMode $captureMode,
|
|
int $subjectsTotal,
|
|
?int $inventorySyncRunId,
|
|
bool $wasNewSnapshot,
|
|
array $evidenceCaptureStats,
|
|
array $gaps,
|
|
): 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,
|
|
'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
|
'capture_mode' => $captureMode->value,
|
|
'inventory_sync_run_id' => $inventorySyncRunId,
|
|
'subjects_total' => $subjectsTotal,
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
|
|
'was_new_snapshot' => $wasNewSnapshot,
|
|
'evidence_capture' => $evidenceCaptureStats,
|
|
'gaps' => $gaps,
|
|
],
|
|
],
|
|
actorId: $initiator?->id,
|
|
actorEmail: $initiator?->email,
|
|
actorName: $initiator?->name,
|
|
resourceType: 'operation_run',
|
|
resourceId: (string) $this->operationRun->getKey(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> ...$gaps
|
|
* @return array<string, int>
|
|
*/
|
|
private function mergeGapCounts(array ...$gaps): array
|
|
{
|
|
$merged = [];
|
|
|
|
foreach ($gaps as $gapMap) {
|
|
foreach ($gapMap as $reason => $count) {
|
|
if (! is_string($reason) || $reason === '') {
|
|
continue;
|
|
}
|
|
|
|
$merged[$reason] = ($merged[$reason] ?? 0) + (int) $count;
|
|
}
|
|
}
|
|
|
|
ksort($merged);
|
|
|
|
return $merged;
|
|
}
|
|
|
|
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
|
{
|
|
$run = OperationRun::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('type', OperationRunType::InventorySync->value)
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->orderByDesc('completed_at')
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
return $run instanceof OperationRun ? $run : null;
|
|
}
|
|
}
|