321 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|