TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php
ahmido ef41c9193a feat: add Intune RBAC baseline compare support (#156)
## Summary
- add Intune RBAC Role Definition baseline scope support, capture references, compare classification, findings evidence, and landing/detail UI labels
- keep Intune Role Assignments explicitly excluded from baseline compare scope, summaries, findings, and restore messaging
- add focused Pest coverage for baseline scope selection, capture, compare behavior, recurrence, isolation, findings rendering, inventory anchoring, and RBAC summaries

## Verification
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php tests/Unit/Baselines/BaselinePolicyVersionResolverTest.php tests/Unit/Baselines/BaselineScopeTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Filament/BaselineProfileFoundationScopeTest.php tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php tests/Feature/Filament/FindingViewRbacEvidenceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/DriftStaleAutoResolveTest.php tests/Feature/Inventory/InventorySyncButtonTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
- result: `71 passed (467 assertions)`

## Filament / Platform Notes
- Livewire compliance: unchanged and compatible with Livewire v4.0+
- Provider registration: no panel/provider changes; `bootstrap/providers.php` remains the registration location
- Global search: no new globally searchable resource added; existing global search behavior is unchanged
- Destructive actions: no new destructive actions introduced; existing confirmed actions remain unchanged
- Assets: no new Filament assets introduced; deploy asset handling remains unchanged, including `php artisan filament:assets`
- Testing plan covered: baseline profile scope, snapshot detail, compare job, findings recurrence, findings detail, compare landing labels, inventory sync anchoring, and tenant isolation

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #156
2026-03-09 18:49:20 +00:00

2361 lines
92 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
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;
/**
* @var array<int, string>
*/
private array $baselineContentHashCache = [];
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 $snapshotIdentity,
AuditLogger $auditLogger,
OperationRunService $operationRunService,
?SettingsResolver $settingsResolver = null,
?BaselineAutoCloseService $baselineAutoCloseService = null,
?CurrentStateHashResolver $hashResolver = null,
?MetaEvidenceProvider $metaEvidenceProvider = null,
?BaselineContentCapturePhase $contentCapturePhase = null,
?BaselineFullContentRolloutGate $rolloutGate = null,
?ContentEvidenceProvider $contentEvidenceProvider = null,
): void {
$settingsResolver ??= app(SettingsResolver::class);
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
$hashResolver ??= app(CurrentStateHashResolver::class);
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
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.");
}
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
if (! $workspace instanceof Workspace) {
throw new RuntimeException("Workspace #{$tenant->workspace_id} not found.");
}
$initiator = $this->operationRun->user_id
? User::query()->find($this->operationRun->user_id)
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$effectiveTypes = $effectiveScope->allTypes();
$scopeKey = 'baseline_profile:'.$profile->getKey();
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
: BaselineCaptureMode::Opportunistic;
if ($captureMode === BaselineCaptureMode::FullContent) {
try {
$rolloutGate->assertEnabled();
} catch (RuntimeException) {
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
);
$effectiveTypeCount = count($effectiveTypes);
$gapCount = max(1, $effectiveTypeCount);
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: null,
coverageProof: false,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: $gapCount,
captureMode: $captureMode,
reasonCode: BaselineCompareReasonCode::RolloutDisabled,
evidenceGapsByReason: [
BaselineCompareReasonCode::RolloutDisabled->value => $gapCount,
],
);
return;
}
}
if ($effectiveTypes === []) {
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
);
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: null,
coverageProof: false,
effectiveTypes: [],
coveredTypes: [],
uncoveredTypes: [],
errorsRecorded: 1,
captureMode: $captureMode,
reasonCode: BaselineCompareReasonCode::NoSubjectsInScope,
evidenceGapsByReason: [],
);
return;
}
$inventorySyncRun = $this->resolveLatestInventorySyncRun($tenant);
$coverage = $inventorySyncRun instanceof OperationRun
? InventoryCoverage::fromContext($inventorySyncRun->context)
: null;
if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) {
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
);
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: false,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
captureMode: $captureMode,
);
return;
}
$coveredTypes = array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()));
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
if ($coveredTypes === []) {
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
);
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: true,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
captureMode: $captureMode,
);
return;
}
$snapshot = BaselineSnapshot::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId)
->first(['id', 'captured_at']);
if (! $snapshot instanceof BaselineSnapshot) {
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
}
$since = $snapshot->captured_at instanceof \DateTimeInterface
? CarbonImmutable::instance($snapshot->captured_at)
: null;
$baselineResult = $this->loadBaselineItems($snapshotId, $coveredTypes);
$baselineItems = $baselineResult['items'];
$baselineGaps = $baselineResult['gaps'];
$currentResult = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey());
$currentItems = $currentResult['items'];
$currentGaps = $currentResult['gaps'];
$ambiguousKeys = array_values(array_unique(array_filter(array_merge(
is_array($baselineResult['ambiguous_keys'] ?? null) ? $baselineResult['ambiguous_keys'] : [],
is_array($currentResult['ambiguous_keys'] ?? null) ? $currentResult['ambiguous_keys'] : [],
), 'is_string')));
foreach ($ambiguousKeys as $ambiguousKey) {
unset($baselineItems[$ambiguousKey], $currentItems[$ambiguousKey]);
}
$subjects = array_values(array_map(
static fn (array $item): array => [
'policy_type' => (string) $item['policy_type'],
'subject_external_id' => (string) $item['subject_external_id'],
],
$currentItems,
));
$subjectsTotal = count($subjects);
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: $subjectsTotal,
effectiveScope: $effectiveScope,
);
$phaseStats = [
'requested' => 0,
'succeeded' => 0,
'skipped' => 0,
'failed' => 0,
'throttled' => 0,
];
$phaseResult = [];
$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_compare'] ?? null)) {
$resumeTokenIn = $context['baseline_compare']['resume_token'] ?? null;
}
$phaseResult = $contentCapturePhase->capture(
tenant: $tenant,
subjects: $subjects,
purpose: PolicyVersionCapturePurpose::BaselineCompare,
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;
}
$resolvedCurrentEvidenceByExternalId = $hashResolver->resolveForSubjects(
tenant: $tenant,
subjects: $subjects,
since: $since,
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
);
$resolvedCurrentEvidenceByExternalId = array_replace(
$resolvedCurrentEvidenceByExternalId,
$this->resolveCapturedCurrentEvidenceByExternalId($phaseResult),
);
$resolvedCurrentMetaEvidenceByExternalId = $metaEvidenceProvider->resolve(
tenant: $tenant,
subjects: $subjects,
since: $since,
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
);
$resolvedCurrentEvidence = $this->rekeyResolvedEvidenceBySubjectKey(
currentItems: $currentItems,
resolvedByExternalId: $resolvedCurrentEvidenceByExternalId,
);
$resolvedCurrentMetaEvidence = $this->rekeyResolvedEvidenceBySubjectKey(
currentItems: $currentItems,
resolvedByExternalId: $resolvedCurrentMetaEvidenceByExternalId,
);
$resolvedEffectiveCurrentEvidence = $this->resolveEffectiveCurrentEvidence(
baselineItems: $baselineItems,
currentItems: $currentItems,
resolvedBestEvidence: $resolvedCurrentEvidence,
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
);
$baselinePolicyVersionResolver = app(BaselinePolicyVersionResolver::class);
$driftHasher = app(DriftHasher::class);
$settingsNormalizer = app(SettingsNormalizer::class);
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
$computeResult = $this->computeDrift(
tenant: $tenant,
baselineProfileId: (int) $profile->getKey(),
baselineSnapshotId: (int) $snapshot->getKey(),
compareOperationRunId: (int) $this->operationRun->getKey(),
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
baselineItems: $baselineItems,
currentItems: $currentItems,
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
hasher: $driftHasher,
settingsNormalizer: $settingsNormalizer,
assignmentsNormalizer: $assignmentsNormalizer,
scopeTagsNormalizer: $scopeTagsNormalizer,
contentEvidenceProvider: $contentEvidenceProvider,
);
$driftResults = $computeResult['drift'];
$driftGaps = $computeResult['evidence_gaps'];
$rbacRoleDefinitionSummary = $computeResult['rbac_role_definitions'];
$upsertResult = $this->upsertFindings(
$tenant,
$profile,
$scopeKey,
$driftResults,
);
$severityBreakdown = $this->countBySeverity($driftResults);
$countsByChangeType = $this->countByChangeType($driftResults);
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
$gapsCount = array_sum($gapsByReason);
$summaryCounts = [
'total' => count($driftResults),
'processed' => count($driftResults),
'succeeded' => (int) $upsertResult['processed_count'],
'failed' => 0,
'errors_recorded' => count($uncoveredTypes),
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
'findings_created' => (int) $upsertResult['created_count'],
'findings_reopened' => (int) $upsertResult['reopened_count'],
'findings_unchanged' => (int) $upsertResult['unchanged_count'],
];
$warningsRecorded = $uncoveredTypes !== [] || $resumeToken !== null || $gapsByReason !== [];
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: $summaryCounts,
);
$resolvedCount = 0;
if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) {
$resolvedCount = $baselineAutoCloseService->resolveStaleFindings(
tenant: $tenant,
baselineProfileId: (int) $profile->getKey(),
seenFingerprints: $upsertResult['seen_fingerprints'],
currentOperationRunId: (int) $this->operationRun->getKey(),
);
$summaryCounts['findings_resolved'] = $resolvedCount;
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: $summaryCounts,
);
}
$coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedEffectiveCurrentEvidence);
$baselineCoverage = $this->summarizeBaselineEvidenceCoverage($baselineItems);
$overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0
|| ($coverageBreakdown['resolved_meta'] ?? 0) > 0
|| ($gapsByReason['missing_current'] ?? 0) > 0
? EvidenceProvenance::FidelityMeta
: EvidenceProvenance::FidelityContent;
$reasonCode = null;
if ($subjectsTotal === 0) {
$reasonCode = BaselineCompareReasonCode::NoSubjectsInScope;
} elseif (count($driftResults) === 0) {
$reasonCode = match (true) {
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
default => BaselineCompareReasonCode::NoDriftDetected,
};
}
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['baseline_compare'] = array_merge(
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
[
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
'since' => $since?->toIso8601String(),
'subjects_total' => $subjectsTotal,
'evidence_capture' => $phaseStats,
'evidence_gaps' => [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
...$gapsByReason,
],
'resume_token' => $resumeToken,
'coverage' => [
'effective_types' => $effectiveTypes,
'covered_types' => $coveredTypes,
'uncovered_types' => $uncoveredTypes,
'proof' => true,
...$coverageBreakdown,
...$baselineCoverage,
],
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
'fidelity' => $overallFidelity,
'reason_code' => $reasonCode?->value,
],
);
$updatedContext['findings'] = array_merge(
is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [],
[
'counts_by_change_type' => $countsByChangeType,
],
);
$updatedContext['result'] = [
'findings_total' => count($driftResults),
'findings_upserted' => (int) $upsertResult['processed_count'],
'findings_resolved' => $resolvedCount,
'severity_breakdown' => $severityBreakdown,
];
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: $subjectsTotal,
evidenceCaptureStats: $phaseStats,
gaps: [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
],
summaryCounts: $summaryCounts,
);
}
/**
* Baseline hashes depend on which evidence fidelity was available at capture time (content vs meta).
*
* Current state evidence is therefore selected to be comparable to the baseline hash:
* - If baseline evidence fidelity is meta: force meta evidence for current (inventory meta contract).
* - If baseline evidence fidelity is content: require current content evidence (since-rule); otherwise treat as a gap.
*
* @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, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedBestEvidence
* @param array<string, ResolvedEvidence> $resolvedMetaEvidence
* @return array<string, ResolvedEvidence|null>
*/
private function resolveEffectiveCurrentEvidence(
array $baselineItems,
array $currentItems,
array $resolvedBestEvidence,
array $resolvedMetaEvidence,
): array {
/**
* @var array<string, string>
*/
$baselineFidelityByKey = [];
foreach ($baselineItems as $key => $baselineItem) {
$provenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
$baselineFidelityByKey[$key] = (string) ($provenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
}
$effective = [];
foreach ($currentItems as $key => $currentItem) {
if (array_key_exists($key, $baselineItems)) {
$baselineFidelity = $baselineFidelityByKey[$key] ?? EvidenceProvenance::FidelityMeta;
if ($baselineFidelity === EvidenceProvenance::FidelityMeta) {
$effective[$key] = $resolvedMetaEvidence[$key] ?? null;
continue;
}
$best = $resolvedBestEvidence[$key] ?? null;
if ($best instanceof ResolvedEvidence && $best->fidelity === EvidenceProvenance::FidelityContent) {
$effective[$key] = $best;
} else {
$effective[$key] = null;
}
continue;
}
$effective[$key] = $resolvedBestEvidence[$key] ?? null;
}
return $effective;
}
/**
* Rekey resolved evidence from "policy_type|external_id" to the current items key ("policy_type|subject_key").
*
* @param array<string, array{subject_external_id: string, policy_type: string}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedByExternalId
* @return array<string, ResolvedEvidence|null>
*/
private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $resolvedByExternalId): array
{
$rekeyed = [];
foreach ($currentItems as $key => $currentItem) {
$policyType = (string) ($currentItem['policy_type'] ?? '');
$externalId = (string) ($currentItem['subject_external_id'] ?? '');
if ($policyType === '' || $externalId === '') {
$rekeyed[$key] = null;
continue;
}
$resolvedKey = $policyType.'|'.$externalId;
$rekeyed[$key] = $resolvedByExternalId[$resolvedKey] ?? null;
}
return $rekeyed;
}
/**
* @param array{
* captured_versions?: array<string, array{
* policy_type: string,
* subject_external_id: string,
* version: PolicyVersion,
* observed_at: string,
* observed_operation_run_id: ?int
* }>
* } $phaseResult
* @return array<string, ResolvedEvidence>
*/
private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult): array
{
$capturedVersions = is_array($phaseResult['captured_versions'] ?? null)
? $phaseResult['captured_versions']
: [];
if ($capturedVersions === []) {
return [];
}
$contentEvidenceProvider = app(ContentEvidenceProvider::class);
$resolved = [];
foreach ($capturedVersions as $key => $capturedVersion) {
$version = $capturedVersion['version'] ?? null;
$subjectExternalId = trim((string) ($capturedVersion['subject_external_id'] ?? ''));
$observedAt = $capturedVersion['observed_at'] ?? null;
$observedAt = is_string($observedAt) && $observedAt !== ''
? CarbonImmutable::parse($observedAt)
: null;
$observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null;
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null;
if (! $version instanceof PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
continue;
}
$resolved[$key] = $contentEvidenceProvider->fromPolicyVersion(
version: $version,
subjectExternalId: $subjectExternalId,
observedAt: $observedAt,
observedOperationRunId: $observedOperationRunId,
);
}
return $resolved;
}
private function completeWithCoverageWarning(
OperationRunService $operationRunService,
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
?OperationRun $inventorySyncRun,
bool $coverageProof,
array $effectiveTypes,
array $coveredTypes,
array $uncoveredTypes,
int $errorsRecorded,
BaselineCaptureMode $captureMode,
BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven,
?array $evidenceGapsByReason = null,
): void {
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'errors_recorded' => max(1, $errorsRecorded),
'high' => 0,
'medium' => 0,
'low' => 0,
'findings_created' => 0,
'findings_reopened' => 0,
'findings_unchanged' => 0,
];
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: $summaryCounts,
);
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$evidenceCapture = [
'requested' => 0,
'succeeded' => 0,
'skipped' => 0,
'failed' => 0,
'throttled' => 0,
];
$evidenceGapsByReason ??= [
BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded),
];
$updatedContext['baseline_compare'] = array_merge(
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
[
'inventory_sync_run_id' => $inventorySyncRun instanceof OperationRun ? (int) $inventorySyncRun->getKey() : null,
'subjects_total' => 0,
'evidence_capture' => $evidenceCapture,
'evidence_gaps' => [
'count' => array_sum($evidenceGapsByReason),
'by_reason' => $evidenceGapsByReason,
...$evidenceGapsByReason,
],
'resume_token' => null,
'reason_code' => $reasonCode->value,
'coverage' => [
'effective_types' => array_values($effectiveTypes),
'covered_types' => array_values($coveredTypes),
'uncovered_types' => array_values($uncoveredTypes),
'proof' => $coverageProof,
'subjects_total' => 0,
'resolved_total' => 0,
'resolved_content' => 0,
'resolved_meta' => 0,
'policy_types_content' => [],
'policy_types_meta_only' => [],
],
'rbac_role_definitions' => $this->emptyRbacRoleDefinitionSummary(),
'fidelity' => 'meta',
],
);
$updatedContext['findings'] = array_merge(
is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [],
[
'counts_by_change_type' => [],
],
);
$updatedContext['result'] = [
'findings_total' => 0,
'findings_upserted' => 0,
'findings_resolved' => 0,
'severity_breakdown' => [],
];
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
evidenceCaptureStats: $evidenceCapture,
gaps: [
'count' => array_sum($evidenceGapsByReason),
'by_reason' => $evidenceGapsByReason,
],
summaryCounts: $summaryCounts,
);
}
/**
* Load baseline snapshot items keyed by "policy_type|subject_key".
*
* @return array{
* items: array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>,
* gaps: array<string, int>,
* ambiguous_keys: list<string>
* }
*/
private function loadBaselineItems(int $snapshotId, array $policyTypes): array
{
$items = [];
$gaps = [];
/**
* @var array<string, true>
*/
$ambiguousKeys = [];
if ($policyTypes === []) {
return [
'items' => $items,
'gaps' => $gaps,
'ambiguous_keys' => [],
];
}
$query = BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshotId);
$query->whereIn('policy_type', $policyTypes);
$query
->orderBy('id')
->chunk(500, function ($snapshotItems) use (&$items, &$gaps, &$ambiguousKeys): void {
foreach ($snapshotItems as $item) {
$metaJsonb = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
$displayName = $metaJsonb['display_name'] ?? ($metaJsonb['displayName'] ?? null);
$displayName = is_string($displayName) ? $displayName : null;
$subjectKey = $this->normalizeSubjectKey(
policyType: (string) $item->policy_type,
storedSubjectKey: is_string($item->subject_key) ? $item->subject_key : null,
displayName: $displayName,
);
if ($subjectKey === '') {
$gaps['missing_subject_key_baseline'] = ($gaps['missing_subject_key_baseline'] ?? 0) + 1;
continue;
}
$key = $item->policy_type.'|'.$subjectKey;
if (array_key_exists($key, $ambiguousKeys)) {
continue;
}
if (array_key_exists($key, $items)) {
$ambiguousKeys[$key] = true;
unset($items[$key]);
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
continue;
}
$items[$key] = [
'subject_type' => (string) $item->subject_type,
'subject_external_id' => (string) $item->subject_external_id,
'subject_key' => $subjectKey,
'policy_type' => (string) $item->policy_type,
'baseline_hash' => (string) $item->baseline_hash,
'meta_jsonb' => $metaJsonb,
];
}
});
ksort($gaps);
return [
'items' => $items,
'gaps' => $gaps,
'ambiguous_keys' => array_values(array_keys($ambiguousKeys)),
];
}
/**
* Load current inventory items keyed by "policy_type|subject_key".
*
* @return array{
* items: array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}>,
* gaps: array<string, int>,
* ambiguous_keys: list<string>
* }
*/
private function loadCurrentInventory(
Tenant $tenant,
array $policyTypes,
?int $latestInventorySyncRunId = null,
): array {
$query = InventoryItem::query()
->where('tenant_id', $tenant->getKey());
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
}
if ($policyTypes === []) {
return [
'items' => [],
'gaps' => [],
'ambiguous_keys' => [],
];
}
$query->whereIn('policy_type', $policyTypes);
$items = [];
$gaps = [];
/**
* @var array<string, true>
*/
$ambiguousKeys = [];
$query->orderBy('policy_type')
->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$items, &$gaps, &$ambiguousKeys): void {
foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$subjectKey = $this->normalizeSubjectKey(
policyType: (string) $inventoryItem->policy_type,
displayName: is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null,
subjectExternalId: is_string($inventoryItem->external_id) ? $inventoryItem->external_id : null,
);
if ($subjectKey === '') {
$gaps['missing_subject_key_current'] = ($gaps['missing_subject_key_current'] ?? 0) + 1;
continue;
}
$key = $inventoryItem->policy_type.'|'.$subjectKey;
if (array_key_exists($key, $ambiguousKeys)) {
continue;
}
if (array_key_exists($key, $items)) {
$ambiguousKeys[$key] = true;
unset($items[$key]);
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
continue;
}
$items[$key] = [
'subject_external_id' => (string) $inventoryItem->external_id,
'subject_key' => $subjectKey,
'policy_type' => (string) $inventoryItem->policy_type,
'meta_jsonb' => [
'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform,
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
],
];
}
});
ksort($gaps);
return [
'items' => $items,
'gaps' => $gaps,
'ambiguous_keys' => array_values(array_keys($ambiguousKeys)),
];
}
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;
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
* @param array<string, string> $severityMapping
* @return array{
* drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
* evidence_gaps: array<string, int>,
* rbac_role_definitions: array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
* }
*/
private function computeDrift(
Tenant $tenant,
int $baselineProfileId,
int $baselineSnapshotId,
int $compareOperationRunId,
int $inventorySyncRunId,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
DriftHasher $hasher,
SettingsNormalizer $settingsNormalizer,
AssignmentsNormalizer $assignmentsNormalizer,
ScopeTagsNormalizer $scopeTagsNormalizer,
ContentEvidenceProvider $contentEvidenceProvider,
): array {
$drift = [];
$evidenceGaps = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
$baselinePlaceholderProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: null,
);
$currentMissingProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: $inventorySyncRunId,
);
foreach ($baselineItems as $key => $baselineItem) {
$currentItem = $currentItems[$key] ?? null;
$policyType = (string) ($baselineItem['policy_type'] ?? '');
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
tenant: $tenant,
baselineItem: $baselineItem,
baselineProvenance: $baselineProvenance,
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
);
$baselineComparableHash = $this->effectiveBaselineHash(
tenant: $tenant,
baselineItem: $baselineItem,
baselinePolicyVersionId: $baselinePolicyVersionId,
contentEvidenceProvider: $contentEvidenceProvider,
);
if (! is_array($currentItem)) {
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
continue;
}
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
$displayName = is_string($displayName) ? (string) $displayName : null;
$evidence = $this->buildDriftEvidenceContract(
changeType: 'missing_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: $baselineComparableHash,
currentHash: null,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentMissingProvenance,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
summaryKind: 'policy_snapshot',
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: [],
diffKind: 'missing',
);
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['missing']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'missing_policy',
'severity' => $isRbacRoleDefinition
? Finding::SEVERITY_HIGH
: $this->severityForChangeType($severityMapping, 'missing_policy'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => $baselineComparableHash,
'current_hash' => '',
'evidence' => $evidence,
];
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
continue;
}
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($baselineComparableHash !== $currentEvidence->hash) {
$displayName = $currentItem['meta_jsonb']['display_name']
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
$displayName = is_string($displayName) ? (string) $displayName : null;
$roleDefinitionDiff = null;
if ($isRbacRoleDefinition) {
if ($baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
continue;
}
if ($currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
continue;
}
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
normalizer: $roleDefinitionNormalizer,
);
if ($roleDefinitionDiff === null) {
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
continue;
}
}
$summaryKind = $isRbacRoleDefinition
? 'rbac_role_definition'
: $this->selectSummaryKind(
tenant: $tenant,
policyType: $policyType,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
hasher: $hasher,
settingsNormalizer: $settingsNormalizer,
assignmentsNormalizer: $assignmentsNormalizer,
scopeTagsNormalizer: $scopeTagsNormalizer,
);
$evidence = $this->buildDriftEvidenceContract(
changeType: 'different_version',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: $baselineComparableHash,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: $summaryKind,
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: (string) $roleDefinitionDiff['diff_kind'],
roleDefinitionDiff: $roleDefinitionDiff,
);
$rbacRoleDefinitionSummary['modified']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'different_version',
'severity' => $isRbacRoleDefinition
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
: $this->severityForChangeType($severityMapping, 'different_version'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $currentItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => $baselineComparableHash,
'current_hash' => $currentEvidence->hash,
'evidence' => $evidence,
];
continue;
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['unchanged']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
}
foreach ($currentItems as $key => $currentItem) {
if (! array_key_exists($key, $baselineItems)) {
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
continue;
}
$policyType = (string) ($currentItem['policy_type'] ?? '');
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
$displayName = is_string($displayName) ? (string) $displayName : null;
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
continue;
}
$evidence = $this->buildDriftEvidenceContract(
changeType: 'unexpected_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: null,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselinePlaceholderProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: 'policy_snapshot',
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: 'unexpected',
);
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['unexpected']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'unexpected_policy',
'severity' => $isRbacRoleDefinition
? Finding::SEVERITY_MEDIUM
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
'subject_type' => 'policy',
'subject_external_id' => $currentItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => '',
'current_hash' => $currentEvidence->hash,
'evidence' => $evidence,
];
}
}
return [
'drift' => $drift,
'evidence_gaps' => $evidenceGaps,
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
];
}
/**
* @param array{subject_external_id: string, baseline_hash: string} $baselineItem
*/
private function effectiveBaselineHash(
Tenant $tenant,
array $baselineItem,
?int $baselinePolicyVersionId,
ContentEvidenceProvider $contentEvidenceProvider,
): string {
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
if ($baselinePolicyVersionId === null) {
return $storedHash;
}
if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) {
return $this->baselineContentHashCache[$baselinePolicyVersionId];
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion) {
return $storedHash;
}
$hash = $contentEvidenceProvider->fromPolicyVersion(
version: $baselineVersion,
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
)->hash;
$this->baselineContentHashCache[$baselinePolicyVersionId] = $hash;
return $hash;
}
private function resolveBaselinePolicyVersionId(
Tenant $tenant,
array $baselineItem,
array $baselineProvenance,
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
): ?int {
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
if (is_numeric($versionReferenceId)) {
return (int) $versionReferenceId;
}
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
return null;
}
$observedAt = $baselineProvenance['observed_at'] ?? null;
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
if (! is_string($observedAt) || $observedAt === '') {
return null;
}
return $baselinePolicyVersionResolver->resolve(
tenant: $tenant,
policyType: (string) ($baselineItem['policy_type'] ?? ''),
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
observedAt: $observedAt,
);
}
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
{
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
}
private function selectSummaryKind(
Tenant $tenant,
string $policyType,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
DriftHasher $hasher,
SettingsNormalizer $settingsNormalizer,
AssignmentsNormalizer $assignmentsNormalizer,
ScopeTagsNormalizer $scopeTagsNormalizer,
): string {
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
return 'policy_snapshot';
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
$currentVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return 'policy_snapshot';
}
$platform = is_string($baselineVersion->platform ?? null)
? (string) $baselineVersion->platform
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
$baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [];
$currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [];
$baselineNormalized = $settingsNormalizer->normalizeForDiff(
snapshot: $baselineSnapshot,
policyType: $policyType,
platform: $platform,
);
$currentNormalized = $settingsNormalizer->normalizeForDiff(
snapshot: $currentSnapshot,
policyType: $policyType,
platform: $platform,
);
$baselineSnapshotHash = $hasher->hashNormalized([
'settings' => $baselineNormalized,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
]);
$currentSnapshotHash = $hasher->hashNormalized([
'settings' => $currentNormalized,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
]);
if ($baselineSnapshotHash !== $currentSnapshotHash) {
return 'policy_snapshot';
}
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
$baselineAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments),
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
]);
$currentAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments),
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
]);
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
return 'policy_assignments';
}
$baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
$currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
return 'policy_snapshot';
}
$baselineScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $baselineScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
]);
$currentScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $currentScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
]);
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
return 'policy_scope_tags';
}
return 'policy_snapshot';
}
/**
* @return array<string, string>
*/
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
{
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
/**
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
* @param array<string, mixed> $currentProvenance
* @return array<string, mixed>
*/
private function buildDriftEvidenceContract(
string $changeType,
string $policyType,
string $subjectKey,
?string $displayName,
?string $baselineHash,
?string $currentHash,
array $baselineProvenance,
array $currentProvenance,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
string $summaryKind,
int $baselineProfileId,
int $baselineSnapshotId,
int $compareOperationRunId,
int $inventorySyncRunId,
): array {
$fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId);
return [
'change_type' => $changeType,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'display_name' => $displayName,
'summary' => [
'kind' => $summaryKind,
],
'baseline' => [
'policy_version_id' => $baselinePolicyVersionId,
'hash' => $baselineHash,
'provenance' => $baselineProvenance,
],
'current' => [
'policy_version_id' => $currentPolicyVersionId,
'hash' => $currentHash,
'provenance' => $currentProvenance,
],
'fidelity' => $fidelity,
'provenance' => [
'baseline_profile_id' => $baselineProfileId,
'baseline_snapshot_id' => $baselineSnapshotId,
'compare_operation_run_id' => $compareOperationRunId,
'inventory_sync_run_id' => $inventorySyncRunId,
],
];
}
/**
* @param array<string, mixed> $baselineMeta
* @param array<string, mixed> $currentMeta
* @param array{
* baseline: array<string, mixed>,
* current: array<string, mixed>,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* diff_kind: string,
* diff_fingerprint: string
* }|null $roleDefinitionDiff
* @return array{
* diff_kind: string,
* diff_fingerprint: string,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* baseline: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed},
* current: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed}
* }
*/
private function buildRoleDefinitionEvidencePayload(
Tenant $tenant,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
array $baselineMeta,
array $currentMeta,
string $diffKind,
?array $roleDefinitionDiff = null,
): array {
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
? $roleDefinitionDiff['baseline']
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
? $roleDefinitionDiff['current']
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
: $this->roleDefinitionPermissionKeys($changedKeys);
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
? (string) $roleDefinitionDiff['diff_kind']
: $diffKind;
$diffFingerprint = is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
? (string) $roleDefinitionDiff['diff_fingerprint']
: hash(
'sha256',
json_encode([
'diff_kind' => $resolvedDiffKind,
'changed_keys' => $changedKeys,
'baseline' => $baselineNormalized,
'current' => $currentNormalized,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
);
return [
'diff_kind' => $resolvedDiffKind,
'diff_fingerprint' => $diffFingerprint,
'changed_keys' => $changedKeys,
'metadata_keys' => $metadataKeys,
'permission_keys' => $permissionKeys,
'baseline' => [
'normalized' => $baselineNormalized,
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
],
'current' => [
'normalized' => $currentNormalized,
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
],
];
}
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
{
if ($policyVersionId === null) {
return null;
}
return PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($policyVersionId);
}
/**
* @param array<string, mixed> $meta
* @return array<string, mixed>
*/
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
{
if ($version instanceof PolicyVersion) {
return app(IntuneRoleDefinitionNormalizer::class)->flattenForDiff(
is_array($version->snapshot) ? $version->snapshot : [],
'intuneRoleDefinition',
is_string($version->platform ?? null) ? (string) $version->platform : null,
);
}
$normalized = [];
$displayName = $meta['display_name'] ?? null;
if (is_string($displayName) && trim($displayName) !== '') {
$normalized['Role definition > Display name'] = trim($displayName);
}
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
if (is_bool($isBuiltIn)) {
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
}
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
if (is_numeric($rolePermissionCount)) {
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
}
return $normalized;
}
/**
* @param array<string, mixed> $baselineNormalized
* @param array<string, mixed> $currentNormalized
* @return list<string>
*/
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
{
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
sort($keys, SORT_STRING);
return array_values(array_filter($keys, fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null)));
}
/**
* @param list<string> $keys
* @return list<string>
*/
private function roleDefinitionPermissionKeys(array $keys): array
{
return array_values(array_filter(
$keys,
fn (string $key): bool => str_starts_with($key, 'Permission block ')
));
}
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
{
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
return 'content';
}
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
return 'mixed';
}
return 'meta';
}
private function normalizeSubjectKey(
string $policyType,
?string $storedSubjectKey = null,
?string $displayName = null,
?string $subjectExternalId = null,
): string {
$storedSubjectKey = is_string($storedSubjectKey) ? trim(mb_strtolower($storedSubjectKey)) : '';
if ($storedSubjectKey !== '') {
return $storedSubjectKey;
}
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? '';
}
/**
* @return array{
* baseline: array<string, mixed>,
* current: array<string, mixed>,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* diff_kind: string,
* diff_fingerprint: string
* }|null
*/
private function resolveRoleDefinitionDiff(
Tenant $tenant,
int $baselinePolicyVersionId,
int $currentPolicyVersionId,
IntuneRoleDefinitionNormalizer $normalizer,
): ?array {
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return null;
}
return $normalizer->classifyDiff(
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
platform: is_string($currentVersion->platform ?? null)
? (string) $currentVersion->platform
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
);
}
/**
* @param array{diff_kind?: string}|null $roleDefinitionDiff
*/
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
{
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
'metadata_only' => Finding::SEVERITY_LOW,
default => Finding::SEVERITY_HIGH,
};
}
/**
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
*/
private function emptyRbacRoleDefinitionSummary(): array
{
return [
'total_compared' => 0,
'unchanged' => 0,
'modified' => 0,
'missing' => 0,
'unexpected' => 0,
];
}
/**
* @param array<string, int> ...$gaps
* @return array<string, int>
*/
private function mergeGapCounts(array ...$gaps): array
{
$merged = [];
foreach ($gaps as $gap) {
foreach ($gap as $reason => $count) {
if (! is_string($reason) || ! is_numeric($count)) {
continue;
}
$count = (int) $count;
if ($count <= 0) {
continue;
}
$merged[$reason] = ($merged[$reason] ?? 0) + $count;
}
}
ksort($merged);
return $merged;
}
/**
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
* @return array{
* subjects_total: int,
* resolved_total: int,
* resolved_content: int,
* resolved_meta: int,
* policy_types_content: list<string>,
* policy_types_meta_only: list<string>
* }
*/
private function summarizeCurrentEvidenceCoverage(array $currentItems, array $resolvedCurrentEvidence): array
{
$subjectsTotal = count($currentItems);
$resolvedContent = 0;
$resolvedMeta = 0;
/**
* @var array<string, array{resolved_content: int, resolved_meta: int}>
*/
$resolvedByType = [];
foreach ($currentItems as $key => $item) {
$type = (string) ($item['policy_type'] ?? '');
if ($type === '') {
continue;
}
$resolvedByType[$type] ??= ['resolved_content' => 0, 'resolved_meta' => 0];
$evidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $evidence instanceof ResolvedEvidence) {
continue;
}
if ($evidence->fidelity === EvidenceProvenance::FidelityContent) {
$resolvedContent++;
$resolvedByType[$type]['resolved_content']++;
} else {
$resolvedMeta++;
$resolvedByType[$type]['resolved_meta']++;
}
}
$policyTypesContent = [];
$policyTypesMetaOnly = [];
foreach ($resolvedByType as $policyType => $counts) {
if ($counts['resolved_content'] > 0) {
$policyTypesContent[] = $policyType;
continue;
}
if ($counts['resolved_meta'] > 0) {
$policyTypesMetaOnly[] = $policyType;
}
}
sort($policyTypesContent, SORT_STRING);
sort($policyTypesMetaOnly, SORT_STRING);
return [
'subjects_total' => $subjectsTotal,
'resolved_total' => $resolvedContent + $resolvedMeta,
'resolved_content' => $resolvedContent,
'resolved_meta' => $resolvedMeta,
'policy_types_content' => $policyTypesContent,
'policy_types_meta_only' => $policyTypesMetaOnly,
];
}
/**
* @param array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @return array{
* baseline_total: int,
* baseline_content: int,
* baseline_meta: int,
* baseline_policy_types_content: list<string>,
* baseline_policy_types_meta_only: list<string>
* }
*/
private function summarizeBaselineEvidenceCoverage(array $baselineItems): array
{
$baselineTotal = count($baselineItems);
$baselineContent = 0;
$baselineMeta = 0;
/**
* @var array<string, array{baseline_content: int, baseline_meta: int}>
*/
$countsByType = [];
foreach ($baselineItems as $key => $item) {
$type = (string) ($item['policy_type'] ?? '');
if ($type === '') {
continue;
}
$countsByType[$type] ??= ['baseline_content' => 0, 'baseline_meta' => 0];
$provenance = $this->baselineProvenanceFromMetaJsonb($item['meta_jsonb'] ?? []);
$fidelity = (string) ($provenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
if ($fidelity === EvidenceProvenance::FidelityContent) {
$baselineContent++;
$countsByType[$type]['baseline_content']++;
} else {
$baselineMeta++;
$countsByType[$type]['baseline_meta']++;
}
}
$policyTypesContent = [];
$policyTypesMetaOnly = [];
foreach ($countsByType as $policyType => $counts) {
if (($counts['baseline_content'] ?? 0) > 0) {
$policyTypesContent[] = $policyType;
continue;
}
if (($counts['baseline_meta'] ?? 0) > 0) {
$policyTypesMetaOnly[] = $policyType;
}
}
sort($policyTypesContent, SORT_STRING);
sort($policyTypesMetaOnly, SORT_STRING);
return [
'baseline_total' => $baselineTotal,
'baseline_content' => $baselineContent,
'baseline_meta' => $baselineMeta,
'baseline_policy_types_content' => $policyTypesContent,
'baseline_policy_types_meta_only' => $policyTypesMetaOnly,
];
}
/**
* @param array<string, mixed> $metaJsonb
* @return array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int}
*/
private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array
{
$evidence = $metaJsonb;
if (is_array($metaJsonb['evidence'] ?? null)) {
$evidence = $metaJsonb['evidence'];
}
$fidelity = $evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta;
$fidelity = is_string($fidelity) ? strtolower(trim($fidelity)) : EvidenceProvenance::FidelityMeta;
if (! EvidenceProvenance::isValidFidelity($fidelity)) {
$fidelity = EvidenceProvenance::FidelityMeta;
}
$source = $evidence['source'] ?? EvidenceProvenance::SourceInventory;
$source = is_string($source) ? strtolower(trim($source)) : EvidenceProvenance::SourceInventory;
if (! EvidenceProvenance::isValidSource($source)) {
$source = EvidenceProvenance::SourceInventory;
}
$observedAt = $evidence['observed_at'] ?? null;
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
$observedAtCarbon = null;
if (is_string($observedAt) && $observedAt !== '') {
try {
$observedAtCarbon = CarbonImmutable::parse($observedAt);
} catch (\Throwable) {
$observedAtCarbon = null;
}
}
$observedOperationRunId = $evidence['observed_operation_run_id'] ?? null;
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null;
return EvidenceProvenance::build(
fidelity: $fidelity,
source: $source,
observedAt: $observedAtCarbon,
observedOperationRunId: $observedOperationRunId,
);
}
/**
* Upsert drift findings using stable fingerprints.
*
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
*/
private function upsertFindings(
Tenant $tenant,
BaselineProfile $profile,
string $scopeKey,
array $driftResults,
): array {
$tenantId = (int) $tenant->getKey();
$baselineProfileId = (int) $profile->getKey();
$observedAt = CarbonImmutable::now();
$processedCount = 0;
$createdCount = 0;
$reopenedCount = 0;
$unchangedCount = 0;
$seenFingerprints = [];
$slaPolicy = app(FindingSlaPolicy::class);
foreach ($driftResults as $driftItem) {
$subjectKey = (string) ($driftItem['subject_key'] ?? '');
if (trim($subjectKey) === '') {
continue;
}
$recurrenceKey = $this->recurrenceKey(
tenantId: $tenantId,
baselineProfileId: $baselineProfileId,
policyType: (string) ($driftItem['policy_type'] ?? ''),
subjectKey: $subjectKey,
changeType: (string) ($driftItem['change_type'] ?? ''),
);
$fingerprint = $recurrenceKey;
$seenFingerprints[] = $fingerprint;
$finding = Finding::query()
->where('tenant_id', $tenantId)
->where('fingerprint', $fingerprint)
->first();
$isNewFinding = ! $finding instanceof Finding;
if ($isNewFinding) {
$finding = new Finding;
} else {
$this->observeFinding(
finding: $finding,
observedAt: $observedAt,
currentOperationRunId: (int) $this->operationRun->getKey(),
);
}
$finding->forceFill([
'tenant_id' => $tenantId,
'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'],
'fingerprint' => $fingerprint,
'recurrence_key' => $recurrenceKey,
'evidence_jsonb' => $driftItem['evidence'],
'evidence_fidelity' => $driftItem['evidence_fidelity'] ?? EvidenceProvenance::FidelityMeta,
'baseline_operation_run_id' => null,
'current_operation_run_id' => (int) $this->operationRun->getKey(),
]);
if ($isNewFinding) {
$severity = (string) $driftItem['severity'];
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
$finding->forceFill([
'status' => Finding::STATUS_NEW,
'reopened_at' => null,
'resolved_at' => null,
'resolved_reason' => null,
'acknowledged_at' => null,
'acknowledged_by_user_id' => null,
'first_seen_at' => $observedAt,
'last_seen_at' => $observedAt,
'times_seen' => 1,
'sla_days' => $slaDays,
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
]);
$createdCount++;
} elseif ((string) $finding->status === Finding::STATUS_RESOLVED) {
$resolvedAt = $finding->resolved_at !== null
? CarbonImmutable::instance($finding->resolved_at)
: null;
if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) {
$severity = (string) $driftItem['severity'];
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
$finding->forceFill([
'status' => Finding::STATUS_REOPENED,
'reopened_at' => $observedAt,
'resolved_at' => null,
'resolved_reason' => null,
'closed_at' => null,
'closed_reason' => null,
'closed_by_user_id' => null,
'sla_days' => $slaDays,
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
]);
$reopenedCount++;
} else {
$unchangedCount++;
}
} else {
$unchangedCount++;
}
$finding->save();
$processedCount++;
}
return [
'processed_count' => $processedCount,
'created_count' => $createdCount,
'reopened_count' => $reopenedCount,
'unchanged_count' => $unchangedCount,
'seen_fingerprints' => array_values(array_unique($seenFingerprints)),
];
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
$finding->last_seen_at = $observedAt;
}
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) {
$finding->times_seen = max(0, $timesSeen) + 1;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
}
/**
* Stable identity for baseline-compare findings, scoped to a baseline profile + subject key.
*/
private function recurrenceKey(
int $tenantId,
int $baselineProfileId,
string $policyType,
string $subjectKey,
string $changeType,
): string {
$parts = [
(string) $tenantId,
(string) $baselineProfileId,
$this->normalizeKeyPart($policyType),
$this->normalizeKeyPart($subjectKey),
$this->normalizeKeyPart($changeType),
];
return hash('sha256', implode('|', $parts));
}
private function normalizeKeyPart(string $value): string
{
return trim(mb_strtolower($value));
}
/**
* @param array<int, array{change_type: string}> $driftResults
* @return array<string, int>
*/
private function countByChangeType(array $driftResults): array
{
$counts = [];
foreach ($driftResults as $item) {
$changeType = (string) ($item['change_type'] ?? '');
if ($changeType === '') {
continue;
}
$counts[$changeType] = ($counts[$changeType] ?? 0) + 1;
}
ksort($counts);
return $counts;
}
/**
* @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;
}
/**
* @return array<string, string>
*/
private function resolveSeverityMapping(Workspace $workspace, SettingsResolver $settingsResolver): array
{
try {
$mapping = $settingsResolver->resolveValue(
workspace: $workspace,
domain: 'baseline',
key: 'severity_mapping',
);
} catch (\InvalidArgumentException) {
// Settings keys are registry-backed; if this key is missing (e.g. during rollout),
// fall back to built-in defaults rather than failing the entire compare run.
return [];
}
return is_array($mapping) ? $mapping : [];
}
/**
* @param array<string, string> $severityMapping
*/
private function severityForChangeType(array $severityMapping, string $changeType): string
{
$severity = $severityMapping[$changeType] ?? null;
if (! is_string($severity) || $severity === '') {
return match ($changeType) {
'missing_policy' => Finding::SEVERITY_HIGH,
'different_version' => Finding::SEVERITY_MEDIUM,
default => Finding::SEVERITY_LOW,
};
}
return $severity;
}
private function auditStarted(
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
BaselineCaptureMode $captureMode,
int $subjectsTotal,
BaselineScope $effectiveScope,
): 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,
'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
'capture_mode' => $captureMode->value,
'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,
?User $initiator,
BaselineCaptureMode $captureMode,
int $subjectsTotal,
array $evidenceCaptureStats,
array $gaps,
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,
'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
'capture_mode' => $captureMode->value,
'subjects_total' => $subjectsTotal,
'findings_total' => $summaryCounts['total'] ?? 0,
'high' => $summaryCounts['high'] ?? 0,
'medium' => $summaryCounts['medium'] ?? 0,
'low' => $summaryCounts['low'] ?? 0,
'evidence_capture' => $evidenceCaptureStats,
'gaps' => $gaps,
],
],
actorId: $initiator?->id,
actorEmail: $initiator?->email,
actorName: $initiator?->name,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
}