TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php
Ahmed Darrazi 73d84c2706
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m14s
feat: add Exchange structured output readiness
2026-07-08 16:24:20 +02:00

512 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
abstract class ExchangePowerShellTargetEvidenceNormalizer
{
public const string PAYLOAD_SHAPE_VERSION = 'exchange-powershell-normalized-payload-v1';
public function __construct(
protected readonly ExchangePowerShellCommandContracts $contracts,
protected readonly ExchangePowerShellHashInputBuilder $hashes,
) {}
abstract public function resourceType(): string;
abstract public function normalizerVersion(): string;
/**
* @return list<string>
*/
abstract protected function materialFields(): array;
/**
* @return list<list<string>>
*/
protected function identityAliasGroups(): array
{
return [
['id', 'sourceId'],
];
}
public function evaluate(ExchangePowerShellStructuredOutputEnvelope $envelope): ExchangePowerShellNormalizerReadiness
{
$definition = $this->definition();
if ($envelope->resourceType !== $this->resourceType()) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $envelope->resourceType,
definition: $definition,
blockers: ['blocked_target_not_ready'],
itemCount: $envelope->itemCount,
);
}
if (! $envelope->accepted()) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $this->resourceType(),
definition: $definition,
blockers: [$envelope->readinessBlocker ?? 'blocked_unknown_output'],
itemCount: $envelope->itemCount,
);
}
if ($envelope->emptyCollection) {
return ExchangePowerShellNormalizerReadiness::ready(
resourceType: $this->resourceType(),
definition: $definition,
normalizedPreviews: [],
hashPreviews: [],
itemCount: 0,
emptyCollection: true,
);
}
$previews = [];
$seenIdentities = [];
foreach ($envelope->items as $item) {
$identity = $this->stableIdentity($item);
if (($identity['allowed'] ?? false) !== true) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $this->resourceType(),
definition: $definition,
blockers: [(string) ($identity['blocker'] ?? 'blocked_identity_unproven')],
itemCount: $envelope->itemCount,
);
}
$identityKey = (string) $identity['key'];
if (isset($seenIdentities[$identityKey])) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $this->resourceType(),
definition: $definition,
blockers: ['duplicate_stable_identity'],
itemCount: $envelope->itemCount,
);
}
$seenIdentities[$identityKey] = true;
$normalized = $this->normalizePayload($item, $identity);
$hashInput = $this->hashes->build(
resourceType: $this->resourceType(),
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: (string) $definition['command_contract_name'],
commandContractVersion: (string) $definition['command_contract_version'],
payloadShapeVersion: self::PAYLOAD_SHAPE_VERSION,
normalizerVersion: $this->normalizerVersion(),
normalizedPayload: $normalized,
);
$hashPreview = $this->hashes->hash($hashInput);
$previews[] = [
'identity_key' => $identityKey,
'identity_sort_key' => (string) $identity['value'],
'hash_preview' => $hashPreview,
'normalized_preview' => $normalized,
];
}
usort($previews, static function (array $left, array $right): int {
return [$left['identity_sort_key'], $left['identity_key'], $left['hash_preview']]
<=> [$right['identity_sort_key'], $right['identity_key'], $right['hash_preview']];
});
return ExchangePowerShellNormalizerReadiness::ready(
resourceType: $this->resourceType(),
definition: $definition,
normalizedPreviews: array_values(array_map(
static fn (array $preview): array => $preview['normalized_preview'],
$previews,
)),
hashPreviews: array_values(array_map(
static fn (array $preview): string => (string) $preview['hash_preview'],
$previews,
)),
itemCount: count($previews),
);
}
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$contract = $this->contract();
$shape = is_array($contract['response_shape'] ?? null) ? $contract['response_shape'] : [];
return [
'resource_type' => $this->resourceType(),
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
'command_contract_name' => (string) ($contract['command_name'] ?? ''),
'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
'payload_shape_version' => self::PAYLOAD_SHAPE_VERSION,
'normalizer_version' => $this->normalizerVersion(),
'identity_candidate_fields' => $this->stringList($shape['required_identity_candidate_fields'] ?? []),
'material_fields' => $this->materialFields(),
'volatile_fields' => $this->stringList($shape['volatile_fields'] ?? []),
'sensitive_fields' => $this->stringList($shape['sensitive_fields'] ?? []),
'protected_fields' => $this->stringList($shape['protected_configuration_fields'] ?? []),
'ordering_rules' => [
'associative_keys_sorted' => true,
'unordered_lists_sorted_by_canonical_value' => true,
'collection_ordering' => 'stable_identity_value_then_hash_preview',
],
'hash_input_rules' => [
'includes_resource_type' => true,
'includes_source_surface' => true,
'includes_command_contract' => true,
'includes_payload_shape_version' => true,
'includes_normalizer_version' => true,
'excludes_raw_stdout' => true,
'excludes_raw_stderr' => true,
'excludes_operation_run_context' => true,
'excludes_runtime_timestamps' => true,
'excludes_powershell_session_metadata' => true,
'excludes_credential_metadata' => true,
'excludes_permission_metadata' => true,
],
'redaction_policy' => [
'protected_configuration_values' => 'presence_only_redacted',
'sensitive_values' => 'presence_only_redacted',
'raw_provider_payload_default_visible' => false,
],
'readiness_blockers' => [
'blocked_malformed_output',
'blocked_warning_prefixed_output',
'blocked_binary_output',
'blocked_oversized_output',
'blocked_nonzero_exit',
'blocked_timeout',
'identity_conflict',
'missing_stable_external_id',
'derived_identity_blocked',
'display_name_only',
'duplicate_stable_identity',
'unsupported_identity',
'blocked_redaction_unproven',
'blocked_hash_unready',
'blocked_target_not_ready',
],
];
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
protected function normalizePayload(array $payload, array $identity): array
{
$material = [];
$protected = [];
$sensitive = [];
$volatile = $this->volatileFields();
foreach ($this->materialFields() as $field) {
if (! array_key_exists($field, $payload) || in_array($field, $volatile, true)) {
continue;
}
if ($this->isProtectedField($field)) {
$protected[$field] = $this->redactedMarker($payload[$field]);
continue;
}
if ($this->isSensitiveField($field)) {
$sensitive[$field] = $this->redactedMarker($payload[$field]);
continue;
}
$material[$this->settingKey($field)] = $this->normalizeSettingValue($payload[$field]);
}
return $this->sortAssociative([
'canonical_type' => $this->resourceType(),
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
'payload_shape_version' => self::PAYLOAD_SHAPE_VERSION,
'normalizer_version' => $this->normalizerVersion(),
'source_identity' => [
'field' => $identity['field'],
'value' => $identity['value'],
],
'material' => $this->sortAssociative($material),
'protected_configuration' => $this->sortAssociative($protected),
'sensitive_content' => $this->sortAssociative($sensitive),
'diagnostics' => [
'volatile_fields' => $volatile,
'protected_fields' => array_keys($protected),
'sensitive_fields' => array_keys($sensitive),
],
]);
}
/**
* @param array<string, mixed> $payload
* @return array{allowed: bool, key?: string, field?: string, value?: string, blocker?: string}
*/
protected function stableIdentity(array $payload): array
{
foreach ($this->identityAliasGroups() as $group) {
$values = [];
foreach ($group as $field) {
$value = $this->stringValue($payload[$field] ?? null);
if ($value !== null) {
$values[$field] = $value;
}
}
if (count(array_unique(array_values($values))) > 1) {
return ['allowed' => false, 'blocker' => 'identity_conflict'];
}
}
foreach ($this->identityFields() as $field) {
$value = $this->stringValue($payload[$field] ?? null);
if ($value !== null) {
return [
'allowed' => true,
'field' => $field,
'value' => $value,
'key' => $this->resourceType().':'.$field.':'.hash('sha256', $value),
];
}
}
if ($this->hasDisplayOnlyIdentity($payload)) {
return ['allowed' => false, 'blocker' => 'display_name_only'];
}
foreach ($this->derivedIdentityFields() as $field) {
if ($this->stringValue($payload[$field] ?? null) !== null) {
return ['allowed' => false, 'blocker' => 'derived_identity_blocked'];
}
}
return ['allowed' => false, 'blocker' => 'missing_stable_external_id'];
}
/**
* @param list<string> $groupKeys
* @param list<string> $rootKeys
* @return array<string, mixed>
*/
protected function settingGroup(array $payload, array $groupKeys, array $rootKeys = []): array
{
$settings = [];
foreach ($groupKeys as $groupKey) {
$group = $payload[$groupKey] ?? null;
if (! is_array($group)) {
continue;
}
foreach ($group as $key => $value) {
$field = (string) $key;
$settings[$this->settingKey($field)] = $this->redactedOrNormalized($field, $value);
}
}
foreach ($rootKeys as $rootKey) {
if (! array_key_exists($rootKey, $payload)) {
continue;
}
$settings[$this->settingKey($rootKey)] = $this->redactedOrNormalized($rootKey, $payload[$rootKey]);
}
return $this->sortAssociative($settings);
}
protected function redactedOrNormalized(string $field, mixed $value): mixed
{
if ($this->isProtectedField($field) || $this->isSensitiveField($field)) {
return $this->redactedMarker($value);
}
return $this->normalizeSettingValue($value);
}
protected function redactedMarker(mixed $value): array
{
return [
'value' => '[redacted]',
'present' => $value !== null,
'count' => is_array($value) ? count($value) : ($value === null ? 0 : 1),
];
}
protected function normalizeSettingValue(mixed $value): mixed
{
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_values(array_filter(
array_map(fn (mixed $item): mixed => $this->normalizeSettingValue($item), $value),
static fn (mixed $item): bool => $item !== null && $item !== '',
));
usort($items, static fn (mixed $left, mixed $right): int => strcmp(
json_encode($left, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
json_encode($right, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
));
return $items;
}
$normalized = [];
foreach ($value as $key => $nestedValue) {
$normalized[$this->settingKey((string) $key)] = $this->normalizeSettingValue($nestedValue);
}
return $this->sortAssociative($normalized);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function sortAssociative(array $payload): array
{
ksort($payload, SORT_NATURAL | SORT_FLAG_CASE);
foreach ($payload as $key => $value) {
if (is_array($value) && ! array_is_list($value)) {
$payload[$key] = $this->sortAssociative($value);
}
}
return $payload;
}
protected function settingKey(string $key): string
{
$key = str_replace(
['IP', 'TLS', 'OOF', 'TNEF'],
['Ip', 'Tls', 'Oof', 'Tnef'],
$key,
);
return str($key)
->replaceMatches('/[^A-Za-z0-9]+/', '_')
->snake()
->trim('_')
->toString();
}
protected 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;
}
/**
* @return list<string>
*/
protected function identityFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.required_identity_candidate_fields', []));
}
/**
* @return list<string>
*/
protected function derivedIdentityFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.derived_identity_candidate_fields', []));
}
/**
* @return list<string>
*/
protected function volatileFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.volatile_fields', []));
}
protected function isSensitiveField(string $field): bool
{
return in_array(
strtolower($field),
array_map('strtolower', $this->stringList(data_get($this->contract(), 'response_shape.sensitive_fields', []))),
true,
);
}
protected function isProtectedField(string $field): bool
{
return in_array(
strtolower($field),
array_map('strtolower', $this->stringList(data_get($this->contract(), 'response_shape.protected_configuration_fields', []))),
true,
);
}
/**
* @return array<string, mixed>
*/
protected function contract(): array
{
return $this->contracts->contractForCanonicalType($this->resourceType()) ?? [];
}
/**
* @return list<string>
*/
protected function stringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_filter(
array_map(static fn (mixed $item): string => is_string($item) ? trim($item) : '', $value),
static fn (string $item): bool => $item !== '',
));
}
private function hasDisplayOnlyIdentity(array $payload): bool
{
foreach (['DisplayName', 'displayName', 'Name', 'name'] as $field) {
if ($this->stringValue($payload[$field] ?? null) !== null) {
return true;
}
}
return false;
}
}