TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php
Ahmed Darrazi 4c1e14c6bc
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 6m9s
feat: complete spec 422 exchange teams comparable renderable pack
2026-06-30 06:18:15 +02:00

833 lines
25 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangeTeamsComparablePayloadNormalizer
{
/**
* @var list<string>
*/
private const SUPPORTED_TYPES = [
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
];
/**
* @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 = [
'transportRule' => [
'@odata.context',
'@odata.etag',
'Actions',
'ApplyHtmlDisclaimerFallbackAction',
'ApplyHtmlDisclaimerLocation',
'Conditions',
'DeleteMessage',
'DisplayName',
'Enabled',
'ExceptIfFrom',
'ExceptIfRecipientDomainIs',
'Exceptions',
'From',
'Identity',
'Mode',
'Name',
'Priority',
'RecipientDomainIs',
'RedirectMessageTo',
'SenderDomainIs',
'SentTo',
'State',
'SubjectContainsWords',
'actions',
'conditions',
'createdDateTime',
'displayName',
'enabled',
'exceptions',
'id',
'mode',
'modifiedDateTime',
'name',
'priority',
'ruleMode',
'state',
],
'acceptedDomain' => [
'@odata.context',
'@odata.etag',
'Default',
'DisplayName',
'DomainName',
'DomainType',
'Identity',
'IsDefault',
'Name',
'State',
'Status',
'createdDateTime',
'default',
'displayName',
'domainName',
'domainType',
'id',
'isDefault',
'modifiedDateTime',
'name',
'state',
'status',
'type',
],
'appPermissionPolicy' => [
'@odata.context',
'@odata.etag',
'AllowAllApps',
'AllowAppList',
'AssignedGroups',
'AssignedUsers',
'BlockAppList',
'Description',
'DisplayName',
'GlobalCatalogAppsType',
'Identity',
'Name',
'PrivateCatalogAppsType',
'allowedAppIds',
'allowedApps',
'allowAppList',
'assignments',
'assignedGroups',
'assignedUsers',
'blockedAppIds',
'blockedApps',
'blockAppList',
'createdDateTime',
'displayName',
'id',
'mode',
'modifiedDateTime',
'name',
'policyMode',
'targets',
],
'meetingPolicy' => [
'@odata.context',
'@odata.etag',
'AllowAnonymousUsersToJoinMeeting',
'AllowCloudRecording',
'AllowExternalParticipantGiveRequestControl',
'AllowMeetingReactions',
'AllowParticipantGiveRequestControl',
'AllowPowerPointSharing',
'AllowPSTNUsersToBypassLobby',
'AllowSharedNotes',
'AllowTranscription',
'AllowWhiteboard',
'AutoAdmittedUsers',
'ContentSharing',
'Description',
'DisplayName',
'ExternalAccess',
'Identity',
'LobbyBypassSettings',
'MeetingRecordingExpirationDays',
'Name',
'RecordingTranscription',
'ScreenSharingMode',
'State',
'allowAnonymousUsersToJoinMeeting',
'allowCloudRecording',
'allowExternalParticipantGiveRequestControl',
'allowMeetingReactions',
'allowParticipantGiveRequestControl',
'allowPowerPointSharing',
'allowPSTNUsersToBypassLobby',
'allowSharedNotes',
'allowTranscription',
'allowWhiteboard',
'autoAdmittedUsers',
'contentSharing',
'createdDateTime',
'displayName',
'externalAccess',
'id',
'lobbyBypassSettings',
'meetingRecordingExpirationDays',
'modifiedDateTime',
'name',
'recordingTranscription',
'screenSharingMode',
'state',
],
];
/**
* @var list<string>
*/
private const SENSITIVE_CONTENT_KEYS = [
'auditmetadata.rawpayload',
'body',
'chatcontent',
'chatmessage',
'filecontent',
'mailbody',
'mailcontent',
'messagebody',
'mimecontent',
'operationruncontext',
'providerresponse',
'rawpayload',
'recordingcontent',
'recordingtranscript',
'recordingurl',
'transcriptcontent',
'transcripttext',
];
/**
* @var list<string>
*/
private const SENSITIVE_CONTENT_KEY_PARTS = [
'applyhtmldisclaimertext',
'attachmentcontainswords',
'attachmentextensionmatcheswords',
'attachmentmatchespatterns',
'attachmentnamematchespatterns',
'attachmentpropertycontainswords',
'bodycontainswords',
'bodymatchespatterns',
'disclaimertext',
'headercontainsmessageheader',
'headercontainswords',
'headermatchespatterns',
'messageheadercontainswords',
'messageheadermatchespatterns',
'prependsubject',
'setheadername',
'setheadervalue',
'subjectcontainswords',
'subjectmatchespatterns',
'subjectorbodycontainswords',
'subjectorbodymatchespatterns',
];
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' => [],
],
];
}
$redacted = $this->redactedPayload($payload);
return match ($canonicalType) {
'transportRule' => $this->normalizeTransportRule($payload, $redacted),
'acceptedDomain' => $this->normalizeAcceptedDomain($payload, $redacted),
'appPermissionPolicy' => $this->normalizeAppPermissionPolicy($payload, $redacted),
'meetingPolicy' => $this->normalizeMeetingPolicy($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 normalizeTransportRule(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'transportRule',
'supported' => true,
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
'enabled_state' => $this->enabledState($this->firstScalar($payload, ['enabled', 'Enabled', 'state', 'State'])),
'priority_order' => $this->stringValue($this->firstScalar($payload, ['priority', 'Priority'])),
'mode' => $this->firstString($payload, ['mode', 'Mode', 'ruleMode', 'RuleMode']),
'conditions' => $this->settingGroup($payload, ['conditions', 'Conditions'], [
'From',
'RecipientDomainIs',
'SenderDomainIs',
'SentTo',
'SubjectContainsWords',
]),
'actions' => $this->settingGroup($payload, ['actions', 'Actions'], [
'ApplyHtmlDisclaimerFallbackAction',
'ApplyHtmlDisclaimerLocation',
'DeleteMessage',
'RedirectMessageTo',
]),
'exceptions' => $this->settingGroup($payload, ['exceptions', 'Exceptions'], [
'ExceptIfFrom',
'ExceptIfRecipientDomainIs',
]),
'source' => $this->sourceSummary($payload),
'diagnostics' => $this->diagnostics('transportRule', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeAcceptedDomain(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'acceptedDomain',
'supported' => true,
'domain_name' => $this->firstString($payload, ['domainName', 'DomainName', 'name', 'Name', 'displayName', 'DisplayName', 'Identity']),
'domain_type' => $this->firstString($payload, ['domainType', 'DomainType', 'type']),
'is_default' => $this->booleanString($this->firstScalar($payload, ['isDefault', 'IsDefault', 'default', 'Default'])),
'state' => $this->firstString($payload, ['state', 'State', 'status', 'Status']),
'source' => $this->sourceSummary($payload),
'diagnostics' => $this->diagnostics('acceptedDomain', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeAppPermissionPolicy(array $rawPayload, array $payload): array
{
$globalCatalogMode = $this->firstString($payload, ['GlobalCatalogAppsType', 'globalCatalogAppsType']);
$privateCatalogMode = $this->firstString($payload, ['PrivateCatalogAppsType', 'privateCatalogAppsType']);
return $this->sortAssociative([
'canonical_type' => 'appPermissionPolicy',
'supported' => true,
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
'policy_mode' => $this->policyMode([
$this->firstScalar($payload, ['mode', 'policyMode']),
$globalCatalogMode,
$privateCatalogMode,
$this->firstScalar($payload, ['AllowAllApps', 'allowAllApps']),
]),
'allowed_apps' => $this->appList($this->firstExisting($payload, ['allowedApps', 'AllowAppList', 'allowAppList', 'allowedAppIds'])),
'blocked_apps' => $this->appList($this->firstExisting($payload, ['blockedApps', 'BlockAppList', 'blockAppList', 'blockedAppIds'])),
'targets' => $this->settingGroup($payload, ['assignments', 'targets'], ['AssignedGroups', 'assignedGroups', 'AssignedUsers', 'assignedUsers']),
'source' => $this->sourceSummary($payload),
'diagnostics' => $this->diagnostics('appPermissionPolicy', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeMeetingPolicy(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'meetingPolicy',
'supported' => true,
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
'state' => $this->firstString($payload, ['state', 'State']),
'external_access' => $this->settingGroup($payload, ['externalAccess', 'ExternalAccess'], [
'allowAnonymousUsersToJoinMeeting',
'AllowAnonymousUsersToJoinMeeting',
'allowExternalParticipantGiveRequestControl',
'AllowExternalParticipantGiveRequestControl',
]),
'recording_transcription' => $this->settingGroup($payload, ['recordingTranscription', 'RecordingTranscription'], [
'allowCloudRecording',
'AllowCloudRecording',
'allowTranscription',
'AllowTranscription',
'meetingRecordingExpirationDays',
'MeetingRecordingExpirationDays',
]),
'lobby_admission' => $this->settingGroup($payload, ['lobbyBypassSettings', 'LobbyBypassSettings'], [
'autoAdmittedUsers',
'AutoAdmittedUsers',
'allowPSTNUsersToBypassLobby',
'AllowPSTNUsersToBypassLobby',
]),
'content_sharing' => $this->settingGroup($payload, ['contentSharing', 'ContentSharing'], [
'screenSharingMode',
'ScreenSharingMode',
'allowPowerPointSharing',
'AllowPowerPointSharing',
'allowWhiteboard',
'AllowWhiteboard',
'allowSharedNotes',
'AllowSharedNotes',
'allowMeetingReactions',
'AllowMeetingReactions',
'allowParticipantGiveRequestControl',
'AllowParticipantGiveRequestControl',
]),
'source' => $this->sourceSummary($payload),
'diagnostics' => $this->diagnostics('meetingPolicy', $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;
}
/**
* @param array<string, mixed> $payload
* @param list<string> $groupKeys
* @param list<string> $rootKeys
* @return array<string, mixed>
*/
private function settingGroup(array $payload, array $groupKeys, array $rootKeys = []): array
{
$settings = [];
foreach ($groupKeys as $groupKey) {
$group = $payload[$groupKey] ?? null;
if (is_array($group)) {
foreach ($this->normalizeSettingMap($group) as $key => $value) {
$settings[$key] = $value;
}
}
}
foreach ($rootKeys as $rootKey) {
if (! array_key_exists($rootKey, $payload)) {
continue;
}
$settings[$this->settingKey($rootKey)] = $this->normalizeSettingValue($payload[$rootKey]);
}
ksort($settings, SORT_NATURAL | SORT_FLAG_CASE);
return $settings;
}
/**
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
private function normalizeSettingMap(array $settings): array
{
$normalized = [];
foreach ($settings as $key => $value) {
$key = $this->settingKey((string) $key);
if ($this->isUnsafeContentKey($key)) {
$normalized[$key] = '[redacted]';
continue;
}
$normalized[$key] = $this->normalizeSettingValue($value);
}
ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE);
return $normalized;
}
private function normalizeSettingValue(mixed $value): mixed
{
if ($value === '[redacted]') {
return $value;
}
if (is_bool($value)) {
return $value;
}
if (is_scalar($value)) {
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
if (! is_array($value)) {
return null;
}
if (array_is_list($value)) {
$items = array_map(fn (mixed $item): mixed => $this->normalizeSettingValue($item), $value);
$items = array_values(array_filter($items, static fn (mixed $item): bool => $item !== null && $item !== ''));
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;
}
return $this->normalizeSettingMap($value);
}
private function settingKey(string $key): string
{
return str($key)
->replaceMatches('/[^A-Za-z0-9]+/', '_')
->snake()
->trim('_')
->toString();
}
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 list<mixed> $candidates
*/
private function policyMode(array $candidates): ?string
{
$parts = [];
foreach ($candidates as $candidate) {
if (is_bool($candidate)) {
$parts[] = $candidate ? 'allow all apps' : 'selected apps only';
continue;
}
$value = $this->stringValue($candidate);
if ($value !== null) {
$parts[] = $value;
}
}
$parts = array_values(array_unique($parts));
return $parts === [] ? null : implode('; ', $parts);
}
/**
* @return list<array<string, mixed>>
*/
private function appList(mixed $value): array
{
if ($value === null || $value === '') {
return [];
}
$items = is_array($value) && array_is_list($value) ? $value : [$value];
$apps = [];
foreach ($items as $item) {
if (is_array($item)) {
$apps[] = array_filter([
'display_name' => $this->firstString($item, ['displayName', 'DisplayName', 'name', 'Name']),
'app_id' => $this->firstString($item, ['appId', 'AppId', 'id', 'Id', 'externalId']),
], static fn (mixed $nested): bool => $nested !== null && $nested !== '');
continue;
}
$value = $this->stringValue($item);
if ($value !== null) {
$apps[] = ['display_name' => $value];
}
}
$apps = array_values(array_filter($apps, static fn (array $item): bool => $item !== []));
usort($apps, static fn (array $left, array $right): int => strcmp(
json_encode($left, JSON_THROW_ON_ERROR),
json_encode($right, JSON_THROW_ON_ERROR),
));
return $apps;
}
/**
* @param array<string, mixed> $payload
* @return array<string, ?string>
*/
private function sourceSummary(array $payload): array
{
return array_filter([
'version' => $this->firstString($payload, ['sourceVersion', 'source_version']),
'schema_hash' => $this->firstString($payload, ['sourceSchemaHash', 'source_schema_hash']),
], static fn (?string $value): bool => $value !== null && $value !== '');
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, list<string>>
*/
private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array
{
return [
'unsupported_fields' => $this->unsupportedRootFields($canonicalType, $payload),
'redacted_fields' => $this->redactedPaths($payload),
'volatile_fields' => $this->presentVolatileFields($rawPayload),
];
}
/**
* @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),
));
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;
}
}