key(), domainKeys: [GovernanceDomainKey::Entra->value], subjectClasses: [GovernanceSubjectClass::Control->value], subjectTypeKeys: ['conditionalAccessPolicy'], ), ]; } public function compare( CompareOrchestrationContext $context, Tenant $tenant, array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping, ): array { $subjectResults = []; foreach ($baselineItems as $key => $baselineItem) { $currentItem = $currentItems[$key] ?? null; if (! is_array($currentItem)) { $subjectResults[] = $this->driftResult( context: $context, baselineItem: $baselineItem, currentItem: null, currentEvidence: null, changeType: 'missing_policy', severity: 'high', ); continue; } $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; if (! $currentEvidence instanceof ResolvedEvidence) { $subjectResults[] = $this->gapResult( policyType: (string) $baselineItem['policy_type'], subjectKey: (string) $baselineItem['subject_key'], externalSubjectId: (string) $baselineItem['subject_external_id'], operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']), compareState: CompareState::Incomplete, reasonCode: 'missing_current', baselineAvailability: 'available', currentStateAvailability: 'unknown', trustLevel: 'unusable', evidenceQuality: 'missing', ); continue; } $baselineMeta = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : []; $currentMeta = is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []; if ($this->metaFingerprint($baselineMeta) !== $this->metaFingerprint($currentMeta)) { $subjectResults[] = $this->driftResult( context: $context, baselineItem: $baselineItem, currentItem: $currentItem, currentEvidence: $currentEvidence, changeType: 'different_version', severity: 'medium', ); continue; } $subjectResults[] = new CompareSubjectResult( subjectIdentity: $this->identity( policyType: (string) $baselineItem['policy_type'], externalSubjectId: (string) $baselineItem['subject_external_id'], subjectKey: (string) $baselineItem['subject_key'], ), projection: $this->projection( policyType: (string) $baselineItem['policy_type'], operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']), ), baselineAvailability: 'available', currentStateAvailability: 'available', compareState: CompareState::NoDrift, trustLevel: 'trustworthy', evidenceQuality: $currentEvidence->fidelity, ); } foreach ($currentItems as $key => $currentItem) { if (array_key_exists($key, $baselineItems)) { continue; } $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; if (! $currentEvidence instanceof ResolvedEvidence) { $subjectResults[] = $this->gapResult( policyType: (string) $currentItem['policy_type'], subjectKey: (string) $currentItem['subject_key'], externalSubjectId: (string) $currentItem['subject_external_id'], operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']), compareState: CompareState::Incomplete, reasonCode: 'missing_current', baselineAvailability: 'missing', currentStateAvailability: 'unknown', trustLevel: 'unusable', evidenceQuality: 'missing', ); continue; } $subjectResults[] = $this->driftResult( context: $context, baselineItem: null, currentItem: $currentItem, currentEvidence: $currentEvidence, changeType: 'unexpected_policy', severity: 'low', ); } return [ 'subject_results' => $subjectResults, 'diagnostics' => [ 'strategy_family' => 'future_control', 'state_counts' => [ 'drift' => count(array_filter($subjectResults, static fn (CompareSubjectResult $result): bool => $result->compareState === CompareState::Drift)), ], ], ]; } /** * @param array $meta */ private function metaFingerprint(array $meta): string { unset($meta['display_name'], $meta['category'], $meta['platform']); return hash('sha256', json_encode($this->sortRecursive($meta), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } /** * @param array $value * @return array */ private function sortRecursive(array $value): array { foreach ($value as $key => $nestedValue) { if (! is_array($nestedValue)) { continue; } $value[$key] = $this->sortRecursive($nestedValue); } ksort($value, SORT_STRING); return $value; } private function driftResult( CompareOrchestrationContext $context, ?array $baselineItem, ?array $currentItem, ?ResolvedEvidence $currentEvidence, string $changeType, string $severity, ): CompareSubjectResult { $source = $baselineItem ?? $currentItem ?? []; $policyType = (string) ($source['policy_type'] ?? 'conditionalAccessPolicy'); $subjectKey = (string) ($source['subject_key'] ?? 'unknown'); $externalSubjectId = (string) ($source['subject_external_id'] ?? 'unknown'); $operatorLabel = (string) ((($currentItem['meta_jsonb']['display_name'] ?? null) ?: ($baselineItem['meta_jsonb']['display_name'] ?? null) ?: $subjectKey) ?: $subjectKey); $fidelity = $currentEvidence?->fidelity ?? EvidenceProvenance::FidelityMeta; $baselineProvenance = EvidenceProvenance::build( fidelity: EvidenceProvenance::FidelityMeta, source: EvidenceProvenance::SourceInventory, observedAt: null, observedOperationRunId: null, ); $currentProvenance = $currentEvidence?->tenantProvenance() ?? EvidenceProvenance::build( fidelity: $fidelity, source: EvidenceProvenance::SourceInventory, observedAt: null, observedOperationRunId: $context->inventorySyncRunId(), ); return new CompareSubjectResult( subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey), projection: $this->projection($policyType, $operatorLabel), baselineAvailability: $baselineItem === null ? 'missing' : 'available', currentStateAvailability: $currentItem === null ? 'missing' : 'available', compareState: CompareState::Drift, trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? 'trustworthy' : 'limited_confidence', evidenceQuality: $fidelity, severityRecommendation: $severity, findingCandidate: new CompareFindingCandidate( changeType: $changeType, severity: $severity, fingerprintBasis: [ 'policy_type' => $policyType, 'subject_key' => $subjectKey, 'change_type' => $changeType, ], evidencePayload: [ 'change_type' => $changeType, 'policy_type' => $policyType, 'subject_key' => $subjectKey, 'display_name' => $operatorLabel, 'summary' => ['kind' => 'control_snapshot'], 'baseline' => [ 'hash' => $baselineItem['baseline_hash'] ?? null, 'provenance' => $baselineProvenance, ], 'current' => [ 'hash' => $currentEvidence?->hash, 'provenance' => $currentProvenance, ], 'fidelity' => $fidelity, 'provenance' => [ 'baseline_profile_id' => $context->baselineProfileId, 'baseline_snapshot_id' => $context->baselineSnapshotId, 'compare_operation_run_id' => $context->operationRunId, 'inventory_sync_run_id' => $context->inventorySyncRunId(), ], ], ), diagnostics: [ 'strategy_key' => $this->key()->value, ], ); } private function gapResult( string $policyType, string $subjectKey, string $externalSubjectId, string $operatorLabel, CompareState $compareState, string $reasonCode, string $baselineAvailability, string $currentStateAvailability, string $trustLevel, string $evidenceQuality, ): CompareSubjectResult { return new CompareSubjectResult( subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey), projection: $this->projection($policyType, $operatorLabel), baselineAvailability: $baselineAvailability, currentStateAvailability: $currentStateAvailability, compareState: $compareState, trustLevel: $trustLevel, evidenceQuality: $evidenceQuality, diagnostics: [ 'reason_code' => $reasonCode, 'gap_record' => [ 'policy_type' => $policyType, 'subject_key' => $subjectKey, 'subject_class' => SubjectClass::Derived->value, 'resolution_path' => ResolutionPath::Derived->value, 'resolution_outcome' => ResolutionOutcome::CaptureFailed->value, 'operator_action_category' => OperatorActionCategory::RunInventorySync->value, 'structural' => false, 'retryable' => $reasonCode === 'missing_current', 'reason_code' => $reasonCode, 'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])), ], ], ); } private function identity(string $policyType, string $externalSubjectId, string $subjectKey): CompareSubjectIdentity { return new CompareSubjectIdentity( domainKey: GovernanceDomainKey::Entra->value, subjectClass: GovernanceSubjectClass::Control->value, subjectTypeKey: $policyType, externalSubjectId: $externalSubjectId, subjectKey: $subjectKey, ); } private function projection(string $policyType, string $operatorLabel): CompareSubjectProjection { return new CompareSubjectProjection( platformSubjectClass: 'control', domainKey: GovernanceDomainKey::Entra->value, subjectTypeKey: $policyType, operatorLabel: $operatorLabel, summaryKind: 'control_snapshot', ); } } final class FailingCompareStrategy implements CompareStrategy { public function key(): CompareStrategyKey { return CompareStrategyKey::from('failing_control'); } public function capabilities(): array { return [ new CompareStrategyCapability( strategyKey: $this->key(), domainKeys: [GovernanceDomainKey::Entra->value], subjectClasses: [GovernanceSubjectClass::Control->value], subjectTypeKeys: ['conditionalAccessPolicy'], ), ]; } public function compare( CompareOrchestrationContext $context, Tenant $tenant, array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping, ): array { throw new \RuntimeException('Synthetic strategy failure for compare testing.'); } } final class FakeGovernanceSubjectTaxonomyRegistry { private readonly GovernanceSubjectTaxonomyRegistry $inner; public function __construct() { $this->inner = new GovernanceSubjectTaxonomyRegistry; } public function all(): array { return array_values(array_merge($this->inner->all(), [ new GovernanceSubjectType( domainKey: GovernanceDomainKey::Entra, subjectClass: GovernanceSubjectClass::Control, subjectTypeKey: 'conditionalAccessPolicy', label: 'Conditional Access Policy', description: 'Synthetic test-only future domain control', captureSupported: true, compareSupported: true, inventorySupported: true, active: true, supportMode: 'supported', legacyBucket: null, ), ])); } public function active(): array { return array_values(array_filter( $this->all(), static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active, )); } public function activeLegacyBucketKeys(string $legacyBucket): array { $subjectTypes = array_filter( $this->active(), static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket, ); $keys = array_map( static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey, $subjectTypes, ); sort($keys, SORT_STRING); return array_values(array_unique($keys)); } public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType { foreach ($this->all() as $subjectType) { if ($subjectType->domainKey->value !== trim($domainKey)) { continue; } if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) { continue; } return $subjectType; } return null; } public function isKnownDomain(string $domainKey): bool { return $this->inner->isKnownDomain($domainKey); } public function allowsSubjectClass(string $domainKey, string $subjectClass): bool { return $this->inner->allowsSubjectClass($domainKey, $subjectClass); } public function supportsFilters(string $domainKey, string $subjectClass): bool { return $this->inner->supportsFilters($domainKey, $subjectClass); } public function groupLabel(string $domainKey, string $subjectClass): string { return $this->inner->groupLabel($domainKey, $subjectClass); } }