*/ private const MATERIAL_CHANGE_TYPES = [ 'added', 'removed', 'changed', ]; public function __construct( private readonly EntraComparablePayloadNormalizer $normalizer, ) {} /** * @param array $beforePayload * @param array $afterPayload * @return array */ public function compare(string $canonicalType, array $beforePayload, array $afterPayload): array { if (! $this->normalizer->supports($canonicalType)) { return [ 'canonical_type' => $canonicalType, 'supported' => false, 'classification' => 'unsupported_field', 'changed' => false, 'changes' => [[ 'field' => 'canonical_type', 'classification' => 'unsupported_field', 'importance' => 'informational', ]], ]; } $before = $this->normalizer->normalize($canonicalType, $beforePayload); $after = $this->normalizer->normalize($canonicalType, $afterPayload); $changes = [ ...$this->volatileChanges($beforePayload, $afterPayload), ...$this->diagnosticChanges($before, $after), ...$this->materialChanges( $this->materialPayload($before), $this->materialPayload($after), ), ]; $hasMaterialChange = collect($changes) ->contains(fn (array $change): bool => in_array($change['classification'] ?? null, self::MATERIAL_CHANGE_TYPES, true)); return [ 'canonical_type' => $canonicalType, 'supported' => true, 'classification' => $hasMaterialChange ? 'changed' : 'unchanged', 'changed' => $hasMaterialChange, 'changes' => $changes, ]; } /** * @param array $payload * @return array */ private function materialPayload(array $payload): array { unset($payload['diagnostics'], $payload['supported'], $payload['canonical_type']); return $payload; } /** * @param array $before * @param array $after * @return list> */ private function materialChanges(array $before, array $after, string $path = ''): array { $changes = []; $keys = array_values(array_unique([...array_keys($before), ...array_keys($after)])); sort($keys, SORT_NATURAL | SORT_FLAG_CASE); foreach ($keys as $key) { $field = $path === '' ? (string) $key : $path.'.'.(string) $key; $beforeValue = $before[$key] ?? null; $afterValue = $after[$key] ?? null; if (is_array($beforeValue) && is_array($afterValue) && ! array_is_list($beforeValue) && ! array_is_list($afterValue)) { foreach ($this->materialChanges($beforeValue, $afterValue, $field) as $nestedChange) { $changes[] = $nestedChange; } continue; } if ($beforeValue === $afterValue) { continue; } $changes[] = [ 'field' => $field, 'classification' => $this->changeClassification($beforeValue, $afterValue), 'importance' => $this->importance($field), 'before' => $beforeValue, 'after' => $afterValue, ]; } return $changes; } private function changeClassification(mixed $beforeValue, mixed $afterValue): string { if ($this->isEmptyValue($beforeValue) && ! $this->isEmptyValue($afterValue)) { return 'added'; } if (! $this->isEmptyValue($beforeValue) && $this->isEmptyValue($afterValue)) { return 'removed'; } return 'changed'; } private function isEmptyValue(mixed $value): bool { return $value === null || $value === '' || $value === []; } private function importance(string $field): string { if ($field === 'state') { return 'critical'; } if (str_starts_with($field, 'targets.') || str_starts_with($field, 'grant_controls.') || str_starts_with($field, 'session_controls.') || str_starts_with($field, 'conditions.') ) { return 'important'; } return 'informational'; } /** * @param array $beforePayload * @param array $afterPayload * @return list> */ private function volatileChanges(array $beforePayload, array $afterPayload): array { $changes = []; foreach ($this->normalizer->volatileRootFields() as $field) { $before = $beforePayload[$field] ?? null; $after = $afterPayload[$field] ?? null; if ($before === $after) { continue; } $changes[] = [ 'field' => $field, 'classification' => 'ignored_volatile', 'importance' => 'informational', ]; } return $changes; } /** * @param array $before * @param array $after * @return list> */ private function diagnosticChanges(array $before, array $after): array { $changes = []; foreach ($this->diagnosticFieldUnion($before, $after, 'unsupported_fields') as $field) { $changes[] = [ 'field' => $field, 'classification' => 'unsupported_field', 'importance' => 'informational', ]; } foreach ($this->diagnosticFieldUnion($before, $after, 'redacted_fields') as $field) { $changes[] = [ 'field' => $field, 'classification' => 'redacted', 'importance' => 'informational', ]; } return $changes; } /** * @param array $before * @param array $after * @return list */ private function diagnosticFieldUnion(array $before, array $after, string $key): array { $fields = [ ...$this->diagnosticFieldList($before, $key), ...$this->diagnosticFieldList($after, $key), ]; $fields = array_values(array_unique($fields)); sort($fields, SORT_NATURAL | SORT_FLAG_CASE); return $fields; } /** * @param array $payload * @return list */ private function diagnosticFieldList(array $payload, string $key): array { $fields = data_get($payload, 'diagnostics.'.$key, []); if (! is_array($fields)) { return []; } return array_values(array_filter( array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields), static fn (string $field): bool => $field !== '', )); } }