TenantAtlas/apps/platform/app/Services/TenantConfiguration/SecurityComplianceComparablePayloadNormalizer.php
ahmido c49784b305 feat: complete spec 423 security compliance readiness pack (#490)
Spec 423 security compliance readiness pack implementation. Head commit: c49acba7.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #490
2026-06-30 16:03:01 +00:00

837 lines
29 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class SecurityComplianceComparablePayloadNormalizer
{
/**
* @var list<string>
*/
private const SUPPORTED_TYPES = [
'retentionCompliancePolicy',
'labelPolicy',
'dlpCompliancePolicy',
];
/**
* @var list<string>
*/
private const VOLATILE_ROOT_FIELDS = [
'@odata.context',
'@odata.etag',
'createdAt',
'createdDateTime',
'created_at',
'graphContext',
'lastModifiedDateTime',
'modifiedDateTime',
'sourceMetadata',
'source_metadata',
'tcmContext',
'updatedAt',
'updatedDateTime',
'updated_at',
'version',
'whenChanged',
];
/**
* @var array<string, list<string>>
*/
private const SUPPORTED_ROOT_FIELDS = [
'retentionCompliancePolicy' => [
'@odata.context',
'@odata.etag',
'DispositionAction',
'DisplayName',
'Duration',
'Enabled',
'ExcludedGroups',
'ExcludedLocations',
'ExcludedUsers',
'Identity',
'IncludedGroups',
'IncludedLocations',
'IncludedUsers',
'Name',
'RetentionAction',
'RetentionDuration',
'RetentionDurationUnit',
'State',
'displayName',
'duration',
'durationUnit',
'enabled',
'excludedGroups',
'excludedLocations',
'excludedUsers',
'id',
'includedGroups',
'includedLocations',
'includedUsers',
'name',
'retentionAction',
'retentionDuration',
'retentionDurationUnit',
'state',
],
'labelPolicy' => [
'@odata.context',
'@odata.etag',
'DefaultLabel',
'DisplayName',
'ExcludedGroups',
'ExcludedLocations',
'ExcludedUsers',
'Identity',
'IncludedGroups',
'IncludedLocations',
'IncludedUsers',
'Labels',
'Mandatory',
'Name',
'PublishedLabels',
'State',
'defaultLabel',
'displayName',
'excludedGroups',
'excludedLocations',
'excludedUsers',
'id',
'includedGroups',
'includedLocations',
'includedUsers',
'labels',
'mandatory',
'name',
'publishedLabels',
'state',
],
'dlpCompliancePolicy' => [
'@odata.context',
'@odata.etag',
'Actions',
'DisplayName',
'ExcludedLocations',
'Identity',
'IncludedLocations',
'Locations',
'Mode',
'Name',
'Rules',
'State',
'Workloads',
'actions',
'displayName',
'excludedLocations',
'id',
'includedLocations',
'locations',
'mode',
'name',
'rules',
'state',
'workloads',
],
];
/**
* @var list<string>
*/
private const SENSITIVE_CONTENT_KEYS = [
'auditmetadata.rawpayload',
'body',
'casecontent',
'content',
'dlpincidentcontent',
'ediscoverycasecontent',
'filecontent',
'fingerprint',
'mailbody',
'mailcontent',
'messagebody',
'messagecontent',
'providerresponse',
'rawpayload',
'sampledata',
'securityincidentcontent',
];
/**
* @var list<string>
*/
private const SENSITIVE_CONTENT_KEY_PARTS = [
'casecontent',
'contentcontainssensitiveinformation',
'dlpincident',
'ediscovery',
'fingerprint',
'mailcontent',
'messagecontent',
'providerresponse',
'rawpayload',
'securityincident',
];
/**
* @var array<string, array<string, list<string>>>
*/
private const SUPPORTED_NESTED_FIELDS = [
'retentionCompliancePolicy' => [
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
],
'labelPolicy' => [
'PublishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'publishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'Labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'DefaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'defaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
],
'dlpCompliancePolicy' => [
'Locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'Workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
'Rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'],
'rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'],
],
];
/**
* @var array<string, array<string, array<string, list<string>>>>
*/
private const SUPPORTED_NESTED_FIELD_CHILDREN = [
'dlpCompliancePolicy' => [
'Rules' => [
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
],
'rules' => [
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
],
],
];
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' => [],
'manual_review_fields' => [],
],
];
}
$redacted = $this->redactedPayload($payload);
return match ($canonicalType) {
'retentionCompliancePolicy' => $this->normalizeRetentionCompliancePolicy($payload, $redacted),
'labelPolicy' => $this->normalizeLabelPolicy($payload, $redacted),
'dlpCompliancePolicy' => $this->normalizeDlpCompliancePolicy($payload, $redacted),
};
}
/**
* @return list<string>
*/
public function volatileRootFields(): array
{
return self::VOLATILE_ROOT_FIELDS;
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeRetentionCompliancePolicy(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'retentionCompliancePolicy',
'supported' => true,
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
'enabled_state' => $this->enabledState($this->firstScalar($payload, ['Enabled', 'enabled', 'State', 'state'])),
'retention' => [
'duration' => $this->stringValue($this->firstScalar($payload, ['RetentionDuration', 'retentionDuration', 'Duration', 'duration'])),
'duration_unit' => $this->firstString($payload, ['RetentionDurationUnit', 'retentionDurationUnit', 'DurationUnit', 'durationUnit']),
'disposition_action' => $this->firstString($payload, ['DispositionAction', 'dispositionAction', 'RetentionAction', 'retentionAction']),
],
'scope' => [
'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']),
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']),
'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']),
'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']),
'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']),
],
'diagnostics' => $this->diagnostics('retentionCompliancePolicy', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeLabelPolicy(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'labelPolicy',
'supported' => true,
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
'state' => $this->firstString($payload, ['State', 'state']),
'labeling' => [
'published_labels' => $this->labelList($this->firstExisting($payload, ['PublishedLabels', 'publishedLabels', 'Labels', 'labels'])),
'default_label' => $this->labelName($this->firstExisting($payload, ['DefaultLabel', 'defaultLabel'])),
'mandatory' => $this->booleanString($this->firstScalar($payload, ['Mandatory', 'mandatory'])),
],
'scope' => [
'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']),
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']),
'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']),
'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']),
'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']),
],
'diagnostics' => $this->diagnostics('labelPolicy', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeDlpCompliancePolicy(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'dlpCompliancePolicy',
'supported' => true,
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
'state' => $this->firstString($payload, ['State', 'state']),
'mode' => $this->firstString($payload, ['Mode', 'mode']),
'scope' => [
'locations' => $this->listFromFields($payload, ['Locations', 'locations', 'IncludedLocations', 'includedLocations']),
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
'workloads' => $this->listFromFields($payload, ['Workloads', 'workloads']),
],
'actions' => $this->scalarList($this->firstExisting($payload, ['Actions', 'actions'])),
'rules' => $this->ruleList($this->firstExisting($payload, ['Rules', 'rules'])),
'diagnostics' => $this->diagnostics('dlpCompliancePolicy', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function redactedPayload(array $payload): array
{
$redacted = $this->redactor->redact($payload);
$redacted = is_array($redacted) ? $redacted : [];
return $this->redactUnsafeContent($redacted);
}
private function redactUnsafeContent(mixed $value, string $path = ''): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->redactUnsafeContent($item, $path), $value);
}
$redacted = [];
foreach ($value as $key => $nestedValue) {
$key = (string) $key;
$nestedPath = $path === '' ? $key : $path.'.'.$key;
$redacted[$key] = $this->isUnsafeContentKey($nestedPath)
? '[redacted]'
: $this->redactUnsafeContent($nestedValue, $nestedPath);
}
return $redacted;
}
private function isUnsafeContentKey(string $path): bool
{
$normalized = strtolower(str_replace(['_', '-', ' '], '', $path));
$segments = explode('.', $normalized);
$last = end($segments);
foreach (self::SENSITIVE_CONTENT_KEYS as $sensitiveKey) {
if ($normalized === $sensitiveKey || $last === $sensitiveKey) {
return true;
}
}
foreach (self::SENSITIVE_CONTENT_KEY_PARTS as $sensitiveKeyPart) {
if (str_contains($normalized, $sensitiveKeyPart) || str_contains((string) $last, $sensitiveKeyPart)) {
return true;
}
}
return false;
}
/**
* @return array<string, list<string>>
*/
private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array
{
$redactedFields = $this->redactedPaths($payload);
$unsupportedFields = $this->unsupportedRootFields($canonicalType, $payload);
return [
'unsupported_fields' => $unsupportedFields,
'redacted_fields' => $redactedFields,
'volatile_fields' => $this->presentVolatileFields($rawPayload),
'manual_review_fields' => $this->manualReviewFields($unsupportedFields, $redactedFields),
];
}
/**
* @param list<string> $unsupportedFields
* @return list<string>
*/
private function manualReviewFields(array $unsupportedFields, array $redactedFields): array
{
$fields = array_values(array_filter(
$unsupportedFields,
static fn (string $field): bool => $field !== '' && ! in_array($field, $redactedFields, true),
));
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
return $fields;
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function unsupportedRootFields(string $canonicalType, array $payload): array
{
$supported = self::SUPPORTED_ROOT_FIELDS[$canonicalType] ?? [];
$fields = array_values(array_filter(
array_map('strval', array_keys($payload)),
static fn (string $key): bool => ! in_array($key, $supported, true)
&& ! in_array($key, self::VOLATILE_ROOT_FIELDS, true)
));
foreach ($this->unsupportedNestedFields($canonicalType, $payload) as $field) {
$fields[] = $field;
}
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
return array_values(array_unique($fields));
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function unsupportedNestedFields(string $canonicalType, array $payload): array
{
$fields = [];
$supportedNestedFields = self::SUPPORTED_NESTED_FIELDS[$canonicalType] ?? [];
foreach ($supportedNestedFields as $rootField => $supportedFields) {
if (! array_key_exists($rootField, $payload)) {
continue;
}
foreach ($this->unsupportedNestedFieldsForValue(
$payload[$rootField],
$supportedFields,
$rootField,
self::SUPPORTED_NESTED_FIELD_CHILDREN[$canonicalType][$rootField] ?? [],
) as $field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @param list<string> $supportedFields
* @param array<string, list<string>> $childSchemas
* @return list<string>
*/
private function unsupportedNestedFieldsForValue(mixed $value, array $supportedFields, string $path, array $childSchemas = []): array
{
if (! is_array($value)) {
return [];
}
$fields = [];
if (array_is_list($value)) {
foreach ($value as $index => $nestedValue) {
foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $supportedFields, $path.'.'.(string) $index, $childSchemas) as $field) {
$fields[] = $field;
}
}
return $fields;
}
foreach ($value as $key => $nestedValue) {
$key = (string) $key;
$nestedPath = $path.'.'.$key;
if (! in_array($key, $supportedFields, true)) {
$fields[] = $nestedPath;
continue;
}
$childSchema = $childSchemas[$key] ?? null;
if ($childSchema === null) {
if (is_array($nestedValue)) {
$fields[] = $nestedPath;
}
continue;
}
foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $childSchema, $nestedPath) as $field) {
$fields[] = $field;
}
}
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> $payload
* @param list<string> $fields
* @return list<string>
*/
private function listFromFields(array $payload, array $fields): array
{
$values = [];
foreach ($fields as $field) {
foreach ($this->scalarList($payload[$field] ?? null) as $value) {
$values[] = $value;
}
}
$values = array_values(array_unique($values));
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
return $values;
}
/**
* @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)) {
$label = $this->labelName($item);
if ($label !== null) {
$values[] = $label;
}
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;
}
/**
* @return list<string>
*/
private function labelList(mixed $value): array
{
if ($value === null || $value === '') {
return [];
}
$items = is_array($value) && array_is_list($value) ? $value : [$value];
$labels = [];
foreach ($items as $item) {
$label = $this->labelName($item);
if ($label !== null) {
$labels[] = $label;
}
}
$labels = array_values(array_unique($labels));
sort($labels, SORT_NATURAL | SORT_FLAG_CASE);
return $labels;
}
private function labelName(mixed $value): ?string
{
if (is_array($value)) {
return $this->firstString($value, ['displayName', 'DisplayName', 'name', 'Name']);
}
return $this->stringValue($value);
}
/**
* @return list<array<string, mixed>>
*/
private function ruleList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$items = array_is_list($value) ? $value : [$value];
$rules = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$rules[] = array_filter([
'name' => $this->firstString($item, ['Name', 'name', 'DisplayName', 'displayName']),
'state' => $this->firstString($item, ['State', 'state', 'Enabled', 'enabled']),
'severity' => $this->firstString($item, ['Severity', 'severity']),
'mode' => $this->firstString($item, ['Mode', 'mode']),
'actions' => $this->scalarList($this->firstExisting($item, ['Actions', 'actions'])),
], static fn (mixed $nested): bool => $nested !== null && $nested !== [] && $nested !== '');
}
usort($rules, static fn (array $left, array $right): int => strcmp(
json_encode($left, JSON_THROW_ON_ERROR),
json_encode($right, JSON_THROW_ON_ERROR),
));
return $rules;
}
private function firstExisting(array $payload, array $fields): mixed
{
foreach ($fields as $field) {
if (array_key_exists($field, $payload)) {
return $payload[$field];
}
}
return null;
}
private function firstScalar(array $payload, array $fields): mixed
{
foreach ($fields as $field) {
$value = $payload[$field] ?? null;
if (is_scalar($value)) {
return $value;
}
}
return null;
}
private function firstString(array $payload, array $fields): ?string
{
return $this->stringValue($this->firstScalar($payload, $fields));
}
private function stringValue(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function enabledState(mixed $value): ?string
{
if (is_bool($value)) {
return $value ? 'enabled' : 'disabled';
}
$value = $this->stringValue($value);
if ($value === null) {
return null;
}
return match (strtolower($value)) {
'1', 'enabled', 'true', 'yes' => 'enabled',
'0', 'disabled', 'false', 'no' => 'disabled',
default => $value,
};
}
private function booleanString(mixed $value): ?string
{
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
$value = $this->stringValue($value);
if ($value === null) {
return null;
}
return match (strtolower($value)) {
'1', 'true', 'yes' => 'yes',
'0', 'false', 'no' => 'no',
default => $value,
};
}
/**
* @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;
}
}