*/ private const SUPPORTED_TYPES = [ 'conditionalAccessPolicy', ]; /** * @var list */ private const CONDITIONAL_ACCESS_ROOT_FIELDS = [ '@odata.context', '@odata.etag', '@odata.type', 'conditions', 'createdDateTime', 'description', 'displayName', 'grantControls', 'id', 'modifiedDateTime', 'name', 'sessionControls', 'state', 'templateId', ]; /** * @var list */ private const VOLATILE_ROOT_FIELDS = [ '@odata.context', '@odata.etag', 'createdDateTime', 'modifiedDateTime', ]; public function __construct( private readonly CoveragePayloadRedactor $redactor, ) {} public function supports(string $canonicalType): bool { return in_array($canonicalType, self::SUPPORTED_TYPES, true); } /** * @param array $payload * @return array */ public function normalize(string $canonicalType, array $payload): array { if (! $this->supports($canonicalType)) { return [ 'canonical_type' => $canonicalType, 'supported' => false, 'diagnostics' => [ 'unsupported_fields' => [], 'redacted_fields' => [], 'volatile_fields' => [], ], ]; } return $this->normalizeConditionalAccessPolicy($payload); } /** * @return list */ public function volatileRootFields(): array { return self::VOLATILE_ROOT_FIELDS; } /** * @param array $payload * @return array */ private function normalizeConditionalAccessPolicy(array $payload): array { $redacted = $this->redactor->redact($payload); $redacted = is_array($redacted) ? $redacted : []; return $this->sortAssociative([ 'canonical_type' => 'conditionalAccessPolicy', 'supported' => true, 'display_name' => $this->stringValue($redacted['displayName'] ?? $redacted['name'] ?? null), 'state' => $this->stringValue($redacted['state'] ?? null), 'targets' => [ 'users' => [ 'include_users' => $this->scalarList(data_get($redacted, 'conditions.users.includeUsers')), 'exclude_users' => $this->scalarList(data_get($redacted, 'conditions.users.excludeUsers')), 'include_groups' => $this->scalarList(data_get($redacted, 'conditions.users.includeGroups')), 'exclude_groups' => $this->scalarList(data_get($redacted, 'conditions.users.excludeGroups')), 'include_roles' => $this->scalarList(data_get($redacted, 'conditions.users.includeRoles')), 'exclude_roles' => $this->scalarList(data_get($redacted, 'conditions.users.excludeRoles')), ], 'applications' => [ 'include_applications' => $this->scalarList(data_get($redacted, 'conditions.applications.includeApplications')), 'exclude_applications' => $this->scalarList(data_get($redacted, 'conditions.applications.excludeApplications')), 'include_user_actions' => $this->scalarList(data_get($redacted, 'conditions.applications.includeUserActions')), 'include_authentication_contexts' => $this->scalarList(data_get($redacted, 'conditions.applications.includeAuthenticationContextClassReferences')), ], ], 'conditions' => [ 'client_app_types' => $this->scalarList(data_get($redacted, 'conditions.clientAppTypes')), 'platforms' => [ 'include_platforms' => $this->scalarList(data_get($redacted, 'conditions.platforms.includePlatforms')), 'exclude_platforms' => $this->scalarList(data_get($redacted, 'conditions.platforms.excludePlatforms')), ], 'locations' => [ 'include_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.includeLocations')), 'exclude_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.excludeLocations')), ], 'user_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.userRiskLevels')), 'sign_in_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.signInRiskLevels')), ], 'grant_controls' => [ 'operator' => $this->stringValue(data_get($redacted, 'grantControls.operator')), 'built_in_controls' => $this->scalarList(data_get($redacted, 'grantControls.builtInControls')), 'custom_authentication_factors' => $this->scalarList(data_get($redacted, 'grantControls.customAuthenticationFactors')), 'terms_of_use' => $this->scalarList(data_get($redacted, 'grantControls.termsOfUse')), ], 'session_controls' => $this->normalizeNested(data_get($redacted, 'sessionControls', [])), 'diagnostics' => [ 'unsupported_fields' => $this->unsupportedRootFields($redacted), 'redacted_fields' => $this->redactedPaths($redacted), 'volatile_fields' => $this->presentVolatileFields($payload), ], ]); } /** * @return list */ private function scalarList(mixed $value): array { if ($value === null || $value === '') { return []; } if (! is_array($value)) { return is_scalar($value) ? [trim((string) $value)] : []; } $values = []; foreach ($value as $item) { if (is_array($item)) { foreach ($this->scalarList($item) as $nested) { $values[] = $nested; } continue; } if (is_scalar($item) && trim((string) $item) !== '') { $values[] = trim((string) $item); } } $values = array_values(array_unique($values)); sort($values, SORT_NATURAL | SORT_FLAG_CASE); return $values; } private function stringValue(mixed $value): ?string { if (! is_scalar($value)) { return null; } $value = trim((string) $value); return $value !== '' ? $value : null; } private function normalizeNested(mixed $value): mixed { if (! is_array($value)) { return is_scalar($value) ? $value : null; } if (array_is_list($value)) { $items = array_map(fn (mixed $item): mixed => $this->normalizeNested($item), $value); if ($this->allScalar($items)) { $items = array_map('strval', $items); sort($items, SORT_NATURAL | SORT_FLAG_CASE); } else { usort($items, static fn (mixed $left, mixed $right): int => strcmp( json_encode($left, JSON_THROW_ON_ERROR), json_encode($right, JSON_THROW_ON_ERROR), )); } return $items; } $normalized = []; foreach ($value as $key => $nestedValue) { $key = (string) $key; if (in_array($key, self::VOLATILE_ROOT_FIELDS, true)) { continue; } $normalized[$key] = $this->normalizeNested($nestedValue); } ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE); return $normalized; } /** * @param list $items */ private function allScalar(array $items): bool { foreach ($items as $item) { if (! is_scalar($item) && $item !== null) { return false; } } return true; } /** * @param array $payload * @return list */ private function unsupportedRootFields(array $payload): array { $fields = array_values(array_filter( array_map('strval', array_keys($payload)), static fn (string $key): bool => ! in_array($key, self::CONDITIONAL_ACCESS_ROOT_FIELDS, true), )); sort($fields, SORT_NATURAL | SORT_FLAG_CASE); return $fields; } /** * @param array $payload * @return list */ private function presentVolatileFields(array $payload): array { $fields = array_values(array_filter( self::VOLATILE_ROOT_FIELDS, static fn (string $field): bool => array_key_exists($field, $payload), )); sort($fields, SORT_NATURAL | SORT_FLAG_CASE); return $fields; } /** * @return list */ private function redactedPaths(mixed $value, string $prefix = ''): array { if ($value === '[redacted]') { return [$prefix]; } if (! is_array($value)) { return []; } $paths = []; foreach ($value as $key => $nestedValue) { $path = $prefix === '' ? (string) $key : $prefix.'.'.(string) $key; foreach ($this->redactedPaths($nestedValue, $path) as $nestedPath) { $paths[] = $nestedPath; } } $paths = array_values(array_unique(array_filter($paths))); sort($paths, SORT_NATURAL | SORT_FLAG_CASE); return $paths; } /** * @param array $value * @return array */ private function sortAssociative(array $value): array { foreach ($value as $key => $nestedValue) { if (is_array($nestedValue)) { $value[$key] = array_is_list($nestedValue) ? $nestedValue : $this->sortAssociative($nestedValue); } } ksort($value, SORT_NATURAL | SORT_FLAG_CASE); return $value; } }