key(), domainKeys: [GovernanceDomainKey::Intune->value], subjectClasses: [GovernanceSubjectClass::Policy->value], subjectTypeKeys: 'all', ), new CompareStrategyCapability( strategyKey: $this->key(), domainKeys: [GovernanceDomainKey::PlatformFoundation->value], subjectClasses: [GovernanceSubjectClass::ConfigurationResource->value], subjectTypeKeys: 'all', ), ]; } public function compare( CompareOrchestrationContext $context, Tenant $tenant, array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping, ): array { $subjectResults = []; $rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary(); $inventorySyncRunId = $context->inventorySyncRunId() ?? 0; $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 > 0 ? $inventorySyncRunId : null, ); 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, ); $baselineComparableHash = $this->effectiveBaselineHash( tenant: $tenant, baselineItem: $baselineItem, baselinePolicyVersionId: $baselinePolicyVersionId, ); if (! is_array($currentItem)) { if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) { $subjectResults[] = $this->gapResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''), operatorLabel: $this->operatorLabel($baselineItem, null), compareState: CompareState::Incomplete, reasonCode: 'missing_role_definition_baseline_version_reference', baselineAvailability: 'available', currentStateAvailability: 'missing', trustLevel: TrustworthinessLevel::Unusable->value, evidenceQuality: 'missing', ); continue; } $evidence = $this->buildDriftEvidenceContract( changeType: 'missing_policy', policyType: $policyType, subjectKey: $subjectKey, displayName: $this->operatorLabel($baselineItem, null), baselineHash: $baselineComparableHash, currentHash: null, baselineProvenance: $baselineProvenance, currentProvenance: $currentMissingProvenance, baselinePolicyVersionId: $baselinePolicyVersionId, currentPolicyVersionId: null, summaryKind: 'policy_snapshot', baselineProfileId: $context->baselineProfileId, baselineSnapshotId: $context->baselineSnapshotId, compareOperationRunId: $context->operationRunId, 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', ); $rbacRoleDefinitionSummary['missing']++; $rbacRoleDefinitionSummary['total_compared']++; } $subjectResults[] = $this->driftResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''), subjectType: is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null, operatorLabel: $this->operatorLabel($baselineItem, null), changeType: 'missing_policy', severity: $isRbacRoleDefinition ? Finding::SEVERITY_HIGH : $this->severityForChangeType($severityMapping, 'missing_policy'), evidence: $evidence, baselineAvailability: 'available', currentStateAvailability: 'missing', ); continue; } $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; if (! $currentEvidence instanceof ResolvedEvidence) { $subjectResults[] = $this->gapResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), operatorLabel: $this->operatorLabel($baselineItem, $currentItem), compareState: CompareState::Incomplete, reasonCode: 'missing_current', baselineAvailability: 'available', currentStateAvailability: 'unknown', trustLevel: TrustworthinessLevel::Unusable->value, evidenceQuality: 'missing', ); continue; } $currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence); if ($baselineComparableHash !== $currentEvidence->hash) { $roleDefinitionDiff = null; if ($isRbacRoleDefinition) { if ($baselinePolicyVersionId === null) { $subjectResults[] = $this->gapResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), operatorLabel: $this->operatorLabel($baselineItem, $currentItem), compareState: CompareState::Incomplete, reasonCode: 'missing_role_definition_baseline_version_reference', baselineAvailability: 'available', currentStateAvailability: 'available', trustLevel: TrustworthinessLevel::Unusable->value, evidenceQuality: $currentEvidence->fidelity, ); continue; } if ($currentPolicyVersionId === null) { $subjectResults[] = $this->gapResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), operatorLabel: $this->operatorLabel($baselineItem, $currentItem), compareState: CompareState::Incomplete, reasonCode: 'missing_role_definition_current_version_reference', baselineAvailability: 'available', currentStateAvailability: 'available', trustLevel: TrustworthinessLevel::Unusable->value, evidenceQuality: $currentEvidence->fidelity, ); continue; } $roleDefinitionDiff = $this->resolveRoleDefinitionDiff( tenant: $tenant, baselinePolicyVersionId: $baselinePolicyVersionId, currentPolicyVersionId: $currentPolicyVersionId, ); if (! is_array($roleDefinitionDiff)) { $subjectResults[] = $this->gapResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), operatorLabel: $this->operatorLabel($baselineItem, $currentItem), compareState: CompareState::Incomplete, reasonCode: 'missing_role_definition_compare_surface', baselineAvailability: 'available', currentStateAvailability: 'available', trustLevel: TrustworthinessLevel::Unusable->value, evidenceQuality: $currentEvidence->fidelity, ); continue; } } $summaryKind = $isRbacRoleDefinition ? 'rbac_role_definition' : $this->selectSummaryKind( tenant: $tenant, policyType: $policyType, baselinePolicyVersionId: $baselinePolicyVersionId, currentPolicyVersionId: $currentPolicyVersionId, ); $evidence = $this->buildDriftEvidenceContract( changeType: 'different_version', policyType: $policyType, subjectKey: $subjectKey, displayName: $this->operatorLabel($baselineItem, $currentItem), baselineHash: $baselineComparableHash, currentHash: (string) $currentEvidence->hash, baselineProvenance: $baselineProvenance, currentProvenance: $currentEvidence->tenantProvenance(), baselinePolicyVersionId: $baselinePolicyVersionId, currentPolicyVersionId: $currentPolicyVersionId, summaryKind: $summaryKind, baselineProfileId: $context->baselineProfileId, baselineSnapshotId: $context->baselineSnapshotId, compareOperationRunId: $context->operationRunId, 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']++; } $subjectResults[] = $this->driftResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), subjectType: is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null, operatorLabel: $this->operatorLabel($baselineItem, $currentItem), changeType: 'different_version', severity: $isRbacRoleDefinition ? $this->severityForRoleDefinitionDiff($roleDefinitionDiff) : $this->severityForChangeType($severityMapping, 'different_version'), evidence: $evidence, baselineAvailability: 'available', currentStateAvailability: 'available', ); continue; } if ($isRbacRoleDefinition) { $rbacRoleDefinitionSummary['unchanged']++; $rbacRoleDefinitionSummary['total_compared']++; } $subjectResults[] = new CompareSubjectResult( subjectIdentity: $this->subjectIdentity($policyType, (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), $subjectKey), projection: $this->subjectProjection($policyType, $this->operatorLabel($baselineItem, $currentItem), is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null, 'policy_snapshot'), baselineAvailability: 'available', currentStateAvailability: 'available', compareState: CompareState::NoDrift, trustLevel: $currentEvidence->fidelity === EvidenceProvenance::FidelityContent ? TrustworthinessLevel::Trustworthy->value : TrustworthinessLevel::LimitedConfidence->value, evidenceQuality: $currentEvidence->fidelity, diagnostics: [ 'strategy_key' => $this->key()->value, ], ); } foreach ($currentItems as $key => $currentItem) { if (array_key_exists($key, $baselineItems)) { continue; } $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; $policyType = (string) ($currentItem['policy_type'] ?? ''); $subjectKey = (string) ($currentItem['subject_key'] ?? ''); $isRbacRoleDefinition = $policyType === 'intuneRoleDefinition'; if (! $currentEvidence instanceof ResolvedEvidence) { $subjectResults[] = $this->gapResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''), operatorLabel: $this->operatorLabel(null, $currentItem), compareState: CompareState::Incomplete, reasonCode: 'missing_current', baselineAvailability: 'missing', currentStateAvailability: 'unknown', trustLevel: TrustworthinessLevel::Unusable->value, evidenceQuality: 'missing', ); continue; } $currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence); if ($isRbacRoleDefinition && $currentPolicyVersionId === null) { $subjectResults[] = $this->gapResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''), operatorLabel: $this->operatorLabel(null, $currentItem), compareState: CompareState::Incomplete, reasonCode: 'missing_role_definition_current_version_reference', baselineAvailability: 'missing', currentStateAvailability: 'available', trustLevel: TrustworthinessLevel::Unusable->value, evidenceQuality: $currentEvidence->fidelity, ); continue; } $evidence = $this->buildDriftEvidenceContract( changeType: 'unexpected_policy', policyType: $policyType, subjectKey: $subjectKey, displayName: $this->operatorLabel(null, $currentItem), baselineHash: null, currentHash: (string) $currentEvidence->hash, baselineProvenance: $baselinePlaceholderProvenance, currentProvenance: $currentEvidence->tenantProvenance(), baselinePolicyVersionId: null, currentPolicyVersionId: $currentPolicyVersionId, summaryKind: 'policy_snapshot', baselineProfileId: $context->baselineProfileId, baselineSnapshotId: $context->baselineSnapshotId, compareOperationRunId: $context->operationRunId, 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', ); $rbacRoleDefinitionSummary['unexpected']++; $rbacRoleDefinitionSummary['total_compared']++; } $subjectResults[] = $this->driftResult( policyType: $policyType, subjectKey: $subjectKey, subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''), subjectType: null, operatorLabel: $this->operatorLabel(null, $currentItem), changeType: 'unexpected_policy', severity: $isRbacRoleDefinition ? Finding::SEVERITY_MEDIUM : $this->severityForChangeType($severityMapping, 'unexpected_policy'), evidence: $evidence, baselineAvailability: 'missing', currentStateAvailability: 'available', ); } return [ 'subject_results' => $subjectResults, 'diagnostics' => [ 'rbac_role_definitions' => $rbacRoleDefinitionSummary, ], ]; } /** * @param array $baselineItem * @param array|null $currentItem * @param array $evidence */ private function driftResult( string $policyType, string $subjectKey, string $subjectExternalId, ?string $subjectType, string $operatorLabel, string $changeType, string $severity, array $evidence, string $baselineAvailability, string $currentStateAvailability, ): CompareSubjectResult { $fidelity = is_string($evidence['fidelity'] ?? null) && trim((string) $evidence['fidelity']) !== '' ? trim((string) $evidence['fidelity']) : EvidenceProvenance::FidelityMeta; return new CompareSubjectResult( subjectIdentity: $this->subjectIdentity($policyType, $subjectExternalId, $subjectKey), projection: $this->subjectProjection( policyType: $policyType, operatorLabel: $operatorLabel, subjectType: $subjectType, summaryKind: is_string(data_get($evidence, 'summary.kind')) ? (string) data_get($evidence, 'summary.kind') : null, ), baselineAvailability: $baselineAvailability, currentStateAvailability: $currentStateAvailability, compareState: CompareState::Drift, trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? TrustworthinessLevel::Trustworthy->value : TrustworthinessLevel::LimitedConfidence->value, evidenceQuality: $fidelity, severityRecommendation: $severity, findingCandidate: new CompareFindingCandidate( changeType: $changeType, severity: $severity, fingerprintBasis: [ 'policy_type' => $policyType, 'subject_key' => $subjectKey, 'change_type' => $changeType, ], evidencePayload: $evidence, ), diagnostics: [ 'strategy_key' => $this->key()->value, ], ); } private function gapResult( string $policyType, string $subjectKey, string $subjectExternalId, string $operatorLabel, CompareState $compareState, string $reasonCode, string $baselineAvailability, string $currentStateAvailability, string $trustLevel, string $evidenceQuality, ): CompareSubjectResult { $descriptor = $this->subjectResolver->describeForCompare( policyType: $policyType, subjectExternalId: $subjectExternalId !== '' ? $subjectExternalId : null, subjectKey: $subjectKey, ); $outcome = match ($reasonCode) { 'missing_current' => $this->subjectResolver->missingExpectedRecord($descriptor), 'ambiguous_match' => $this->subjectResolver->ambiguousMatch($descriptor), default => $this->subjectResolver->captureFailed($descriptor), }; $gapRecord = array_merge($descriptor->toArray(), $outcome->toArray(), [ 'reason_code' => $reasonCode, 'search_text' => strtolower(implode(' ', array_filter([$policyType, $subjectKey, $reasonCode, $operatorLabel]))), ]); return new CompareSubjectResult( subjectIdentity: $this->subjectIdentity($policyType, $subjectExternalId, $subjectKey), projection: $this->subjectProjection($policyType, $operatorLabel, null, null), baselineAvailability: $baselineAvailability, currentStateAvailability: $currentStateAvailability, compareState: $compareState, trustLevel: $trustLevel, evidenceQuality: $evidenceQuality, diagnostics: [ 'strategy_key' => $this->key()->value, 'reason_code' => $reasonCode, 'gap_record' => $gapRecord, ], ); } private function subjectIdentity(string $policyType, string $subjectExternalId, string $subjectKey): CompareSubjectIdentity { return new CompareSubjectIdentity( domainKey: $this->domainKeyFor($policyType), subjectClass: $this->subjectClassFor($policyType), subjectTypeKey: $policyType, externalSubjectId: $subjectExternalId !== '' ? $subjectExternalId : null, subjectKey: $subjectKey, ); } private function subjectProjection(string $policyType, string $operatorLabel, ?string $subjectType, ?string $summaryKind): CompareSubjectProjection { return new CompareSubjectProjection( platformSubjectClass: $this->platformSubjectClassFor($policyType, $subjectType), domainKey: $this->domainKeyFor($policyType), subjectTypeKey: $policyType, operatorLabel: $operatorLabel, summaryKind: $summaryKind, additionalLabels: [ 'policy_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($policyType) ?? $policyType, 'governed_subject_label' => (string) data_get($this->subjectDescriptor($policyType), 'display_label', $policyType), ], subjectDescriptor: $this->subjectDescriptor($policyType), ); } /** * @return array */ private function subjectDescriptor(string $policyType): array { static $cache = []; if (array_key_exists($policyType, $cache)) { return $cache[$policyType]; } $result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([ 'policy_type' => $policyType, ], 'baseline_compare'); return $cache[$policyType] = $result->descriptor->toArray(); } private function domainKeyFor(string $policyType): string { return InventoryPolicyTypeMeta::isFoundation($policyType) ? GovernanceDomainKey::PlatformFoundation->value : GovernanceDomainKey::Intune->value; } private function subjectClassFor(string $policyType): string { return InventoryPolicyTypeMeta::isFoundation($policyType) ? GovernanceSubjectClass::ConfigurationResource->value : GovernanceSubjectClass::Policy->value; } private function platformSubjectClassFor(string $policyType, ?string $subjectType): string { if (is_string($subjectType) && trim($subjectType) !== '') { return trim($subjectType); } return InventoryPolicyTypeMeta::isFoundation($policyType) ? GovernanceSubjectClass::ConfigurationResource->value : 'policy'; } /** * @param array|null $baselineItem * @param array|null $currentItem */ private function operatorLabel(?array $baselineItem, ?array $currentItem): string { $displayName = $currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key'] ?? $baselineItem['subject_key'] ?? 'Unknown subject'; return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : 'Unknown subject'; } private function effectiveBaselineHash(Tenant $tenant, array $baselineItem, ?int $baselinePolicyVersionId): string { $storedHash = (string) ($baselineItem['baseline_hash'] ?? ''); if ($baselinePolicyVersionId === null) { return $storedHash; } $baselineVersion = PolicyVersion::query() ->where('tenant_id', (int) $tenant->getKey()) ->find($baselinePolicyVersionId); if (! $baselineVersion instanceof PolicyVersion) { return $storedHash; } return $this->contentEvidenceProvider->fromPolicyVersion( version: $baselineVersion, subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''), )->hash; } private function resolveBaselinePolicyVersionId(Tenant $tenant, array $baselineItem, array $baselineProvenance): ?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 $this->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): 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); $baselineSnapshotHash = $this->hasher->hashNormalized([ 'settings' => $this->settingsNormalizer->normalizeForDiff( snapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [], policyType: $policyType, platform: $platform, ), 'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'), ]); $currentSnapshotHash = $this->hasher->hashNormalized([ 'settings' => $this->settingsNormalizer->normalizeForDiff( snapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [], policyType: $policyType, platform: $platform, ), 'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'), ]); if ($baselineSnapshotHash !== $currentSnapshotHash) { return 'policy_snapshot'; } $baselineAssignmentsHash = $this->hasher->hashNormalized([ 'assignments' => $this->assignmentsNormalizer->normalizeForDiff(is_array($baselineVersion->assignments) ? $baselineVersion->assignments : []), 'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'), ]); $currentAssignmentsHash = $this->hasher->hashNormalized([ 'assignments' => $this->assignmentsNormalizer->normalizeForDiff(is_array($currentVersion->assignments) ? $currentVersion->assignments : []), 'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'), ]); if ($baselineAssignmentsHash !== $currentAssignmentsHash) { return 'policy_assignments'; } $baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags); $currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags); if ($baselineScopeTagIds === null || $currentScopeTagIds === null) { return 'policy_snapshot'; } $baselineScopeTagsHash = $this->hasher->hashNormalized([ 'scope_tag_ids' => $baselineScopeTagIds, 'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'), ]); $currentScopeTagsHash = $this->hasher->hashNormalized([ 'scope_tag_ids' => $currentScopeTagIds, 'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'), ]); return $baselineScopeTagsHash !== $currentScopeTagsHash ? 'policy_scope_tags' : 'policy_snapshot'; } 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 fingerprintBucket(PolicyVersion $version, string $bucket): array { $secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : []; $bucketFingerprints = $secretFingerprints[$bucket] ?? []; return is_array($bucketFingerprints) ? $bucketFingerprints : []; } 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 { 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' => $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId), 'provenance' => [ 'baseline_profile_id' => $baselineProfileId, 'baseline_snapshot_id' => $baselineSnapshotId, 'compare_operation_run_id' => $compareOperationRunId, 'inventory_sync_run_id' => $inventorySyncRunId, ], ]; } 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; return [ 'diff_kind' => $resolvedDiffKind, 'diff_fingerprint' => 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), ), '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); } private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array { if ($version instanceof PolicyVersion) { return $this->roleDefinitionNormalizer->buildEvidenceMap( is_array($version->snapshot) ? $version->snapshot : [], 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; } 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), )); } private function roleDefinitionPermissionKeys(array $keys): array { return array_values(array_filter( $keys, static 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 resolveRoleDefinitionDiff(Tenant $tenant, int $baselinePolicyVersionId, int $currentPolicyVersionId): ?array { $baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId); $currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId); if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) { return null; } return $this->roleDefinitionNormalizer->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), ); } private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string { return match ($roleDefinitionDiff['diff_kind'] ?? null) { 'metadata_only' => Finding::SEVERITY_LOW, default => Finding::SEVERITY_HIGH, }; } private function emptyRbacRoleDefinitionSummary(): array { return [ 'total_compared' => 0, 'unchanged' => 0, 'modified' => 0, 'missing' => 0, 'unexpected' => 0, ]; } private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array { $evidence = is_array($metaJsonb['evidence'] ?? null) ? $metaJsonb['evidence'] : $metaJsonb; $fidelity = is_string($evidence['fidelity'] ?? null) ? strtolower(trim((string) $evidence['fidelity'])) : EvidenceProvenance::FidelityMeta; $source = is_string($evidence['source'] ?? null) ? strtolower(trim((string) $evidence['source'])) : EvidenceProvenance::SourceInventory; $observedAt = is_string($evidence['observed_at'] ?? null) ? trim((string) $evidence['observed_at']) : null; $observedAtCarbon = null; if ($observedAt !== null && $observedAt !== '') { try { $observedAtCarbon = \Carbon\CarbonImmutable::parse($observedAt); } catch (\Throwable) { $observedAtCarbon = null; } } $observedOperationRunId = $evidence['observed_operation_run_id'] ?? null; return EvidenceProvenance::build( fidelity: EvidenceProvenance::isValidFidelity($fidelity) ? $fidelity : EvidenceProvenance::FidelityMeta, source: EvidenceProvenance::isValidSource($source) ? $source : EvidenceProvenance::SourceInventory, observedAt: $observedAtCarbon, observedOperationRunId: is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null, ); } }