TenantAtlas/apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php
ahmido 33e496c182 feat: complete spec 425 enta certified compare pack (#492)
Implements spec 425 with Entra certified compare pack support, coverage, guards, evaluator, fixtures, and tests.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #492
2026-07-01 23:27:16 +00:00

497 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class EntraComparablePayloadNormalizer
{
/**
* @var list<string>
*/
private const SUPPORTED_TYPES = [
'conditionalAccessPolicy',
'securityDefaults',
];
/**
* @var list<string>
*/
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<string>
*/
private const SECURITY_DEFAULTS_ROOT_FIELDS = [
'@odata.context',
'@odata.etag',
'@odata.type',
'description',
'displayName',
'id',
'isEnabled',
'name',
];
/**
* @var list<string>
*/
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<string, mixed> $payload
* @return array<string, mixed>
*/
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' => [],
],
];
}
if ($canonicalType === 'securityDefaults') {
return $this->normalizeSecurityDefaults($payload);
}
return $this->normalizeConditionalAccessPolicy($payload);
}
/**
* @return list<string>
*/
public function volatileRootFields(): array
{
return self::VOLATILE_ROOT_FIELDS;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
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')),
],
'devices' => $this->conditionalAccessDevices(data_get($redacted, 'conditions.devices', [])),
'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, 'conditionalAccessPolicy'),
'redacted_fields' => $this->redactedPaths($redacted),
'volatile_fields' => $this->presentVolatileFields($payload),
],
]);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeSecurityDefaults(array $payload): array
{
$redacted = $this->redactor->redact($payload);
$redacted = is_array($redacted) ? $redacted : [];
$enabled = $this->boolValue($redacted['isEnabled'] ?? null);
return $this->sortAssociative([
'canonical_type' => 'securityDefaults',
'supported' => true,
'display_name' => $this->stringValue($redacted['displayName'] ?? $redacted['name'] ?? null) ?? 'Security Defaults',
'enabled' => $enabled,
'enabled_state' => match ($enabled) {
true => 'enabled',
false => 'disabled',
default => 'unknown',
},
'source_identity' => [
'id' => $this->stringValue($redacted['id'] ?? null),
],
'diagnostics' => [
'unsupported_fields' => $this->unsupportedRootFields($redacted, 'securityDefaults'),
'redacted_fields' => $this->redactedPaths($redacted),
'volatile_fields' => $this->presentVolatileFields($payload),
],
]);
}
/**
* @return array<string, mixed>
*/
private function conditionalAccessDevices(mixed $value): array
{
$devices = is_array($value) ? $value : [];
$deviceFilter = data_get($devices, 'deviceFilter', []);
$deviceFilter = is_array($deviceFilter) ? $deviceFilter : [];
return [
'include_device_states' => $this->scalarList(data_get($devices, 'includeDeviceStates')),
'exclude_device_states' => $this->scalarList(data_get($devices, 'excludeDeviceStates')),
'include_devices' => $this->scalarList(data_get($devices, 'includeDevices')),
'exclude_devices' => $this->scalarList(data_get($devices, 'excludeDevices')),
'device_filter' => [
'mode' => $this->stringValue(data_get($deviceFilter, 'mode')),
'rule' => $this->stringValue(data_get($deviceFilter, 'rule')),
],
];
}
/**
* @return list<string>
*/
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 boolValue(mixed $value): ?bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value === 1 ? true : ($value === 0 ? false : null);
}
if (! is_string($value)) {
return null;
}
return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'enabled' => true,
'0', 'false', 'no', 'disabled' => false,
default => 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<mixed> $items
*/
private function allScalar(array $items): bool
{
foreach ($items as $item) {
if (! is_scalar($item) && $item !== null) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function unsupportedRootFields(array $payload, string $canonicalType): array
{
$allowedFields = $canonicalType === 'securityDefaults'
? self::SECURITY_DEFAULTS_ROOT_FIELDS
: self::CONDITIONAL_ACCESS_ROOT_FIELDS;
$fields = array_values(array_filter(
array_map('strval', array_keys($payload)),
static fn (string $key): bool => ! in_array($key, $allowedFields, true),
));
$fields = [
...$fields,
...$this->unsupportedConditionalAccessConditionFields($payload, $canonicalType),
];
$fields = array_values(array_unique($fields));
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
return $fields;
}
/**
* @return list<string>
*/
private function unsupportedConditionalAccessConditionFields(array $payload, string $canonicalType): array
{
if ($canonicalType !== 'conditionalAccessPolicy') {
return [];
}
$conditions = $payload['conditions'] ?? null;
if (! is_array($conditions)) {
return [];
}
$fields = [];
$fields = [
...$fields,
...$this->unsupportedNestedFields($conditions, 'conditions', [
'applications',
'clientAppTypes',
'devices',
'locations',
'platforms',
'signInRiskLevels',
'userRiskLevels',
'users',
]),
];
foreach ([
'users' => ['includeUsers', 'excludeUsers', 'includeGroups', 'excludeGroups', 'includeRoles', 'excludeRoles'],
'applications' => ['includeApplications', 'excludeApplications', 'includeUserActions', 'includeAuthenticationContextClassReferences'],
'platforms' => ['includePlatforms', 'excludePlatforms'],
'locations' => ['includeLocations', 'excludeLocations'],
'devices' => ['includeDeviceStates', 'excludeDeviceStates', 'includeDevices', 'excludeDevices', 'deviceFilter'],
] as $key => $allowed) {
$nested = $conditions[$key] ?? null;
if (is_array($nested)) {
$fields = [
...$fields,
...$this->unsupportedNestedFields($nested, 'conditions.'.$key, $allowed),
];
}
}
$deviceFilter = data_get($conditions, 'devices.deviceFilter');
if (is_array($deviceFilter)) {
$fields = [
...$fields,
...$this->unsupportedNestedFields($deviceFilter, 'conditions.devices.deviceFilter', ['mode', 'rule']),
];
}
return $fields;
}
/**
* @param list<string> $allowedFields
* @return list<string>
*/
private function unsupportedNestedFields(array $payload, string $prefix, array $allowedFields): array
{
return array_values(array_map(
static fn (string $field): string => $prefix.'.'.$field,
array_filter(
array_map('strval', array_keys($payload)),
static fn (string $key): bool => ! in_array($key, $allowedFields, true),
),
));
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
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<string>
*/
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<string, mixed> $value
* @return array<string, mixed>
*/
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;
}
}