TenantAtlas/apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php
Ahmed Darrazi 19037e1dd8
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m15s
feat: complete spec 421 Entra comparable/renderable pack
2026-06-27 23:42:58 +02:00

321 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class EntraComparablePayloadNormalizer
{
/**
* @var list<string>
*/
private const SUPPORTED_TYPES = [
'conditionalAccessPolicy',
];
/**
* @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 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' => [],
],
];
}
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')),
],
'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<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 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): 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<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;
}
}