TenantAtlas/apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.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

241 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class EntraCoverageComparator
{
/**
* @var list<string>
*/
private const MATERIAL_CHANGE_TYPES = [
'added',
'removed',
'changed',
];
public function __construct(
private readonly EntraComparablePayloadNormalizer $normalizer,
) {}
/**
* @param array<string, mixed> $beforePayload
* @param array<string, mixed> $afterPayload
* @return array<string, mixed>
*/
public function compare(string $canonicalType, array $beforePayload, array $afterPayload): array
{
if (! $this->normalizer->supports($canonicalType)) {
return [
'canonical_type' => $canonicalType,
'supported' => false,
'classification' => 'unsupported_field',
'changed' => false,
'changes' => [[
'field' => 'canonical_type',
'classification' => 'unsupported_field',
'importance' => 'informational',
]],
];
}
$before = $this->normalizer->normalize($canonicalType, $beforePayload);
$after = $this->normalizer->normalize($canonicalType, $afterPayload);
$changes = [
...$this->volatileChanges($beforePayload, $afterPayload),
...$this->diagnosticChanges($before, $after),
...$this->materialChanges(
$this->materialPayload($before),
$this->materialPayload($after),
),
];
$hasMaterialChange = collect($changes)
->contains(fn (array $change): bool => in_array($change['classification'] ?? null, self::MATERIAL_CHANGE_TYPES, true));
return [
'canonical_type' => $canonicalType,
'supported' => true,
'classification' => $hasMaterialChange ? 'changed' : 'unchanged',
'changed' => $hasMaterialChange,
'changes' => $changes,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function materialPayload(array $payload): array
{
unset($payload['diagnostics'], $payload['supported'], $payload['canonical_type']);
return $payload;
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @return list<array<string, mixed>>
*/
private function materialChanges(array $before, array $after, string $path = ''): array
{
$changes = [];
$keys = array_values(array_unique([...array_keys($before), ...array_keys($after)]));
sort($keys, SORT_NATURAL | SORT_FLAG_CASE);
foreach ($keys as $key) {
$field = $path === '' ? (string) $key : $path.'.'.(string) $key;
$beforeValue = $before[$key] ?? null;
$afterValue = $after[$key] ?? null;
if (is_array($beforeValue) && is_array($afterValue) && ! array_is_list($beforeValue) && ! array_is_list($afterValue)) {
foreach ($this->materialChanges($beforeValue, $afterValue, $field) as $nestedChange) {
$changes[] = $nestedChange;
}
continue;
}
if ($beforeValue === $afterValue) {
continue;
}
$changes[] = [
'field' => $field,
'classification' => $this->changeClassification($beforeValue, $afterValue),
'importance' => $this->importance($field),
'before' => $beforeValue,
'after' => $afterValue,
];
}
return $changes;
}
private function changeClassification(mixed $beforeValue, mixed $afterValue): string
{
if ($this->isEmptyValue($beforeValue) && ! $this->isEmptyValue($afterValue)) {
return 'added';
}
if (! $this->isEmptyValue($beforeValue) && $this->isEmptyValue($afterValue)) {
return 'removed';
}
return 'changed';
}
private function isEmptyValue(mixed $value): bool
{
return $value === null || $value === '' || $value === [];
}
private function importance(string $field): string
{
if ($field === 'state') {
return 'critical';
}
if (str_starts_with($field, 'targets.')
|| str_starts_with($field, 'grant_controls.')
|| str_starts_with($field, 'session_controls.')
|| str_starts_with($field, 'conditions.')
) {
return 'important';
}
return 'informational';
}
/**
* @param array<string, mixed> $beforePayload
* @param array<string, mixed> $afterPayload
* @return list<array<string, mixed>>
*/
private function volatileChanges(array $beforePayload, array $afterPayload): array
{
$changes = [];
foreach ($this->normalizer->volatileRootFields() as $field) {
$before = $beforePayload[$field] ?? null;
$after = $afterPayload[$field] ?? null;
if ($before === $after) {
continue;
}
$changes[] = [
'field' => $field,
'classification' => 'ignored_volatile',
'importance' => 'informational',
];
}
return $changes;
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @return list<array<string, mixed>>
*/
private function diagnosticChanges(array $before, array $after): array
{
$changes = [];
foreach ($this->diagnosticFieldUnion($before, $after, 'unsupported_fields') as $field) {
$changes[] = [
'field' => $field,
'classification' => 'unsupported_field',
'importance' => 'informational',
];
}
foreach ($this->diagnosticFieldUnion($before, $after, 'redacted_fields') as $field) {
$changes[] = [
'field' => $field,
'classification' => 'redacted',
'importance' => 'informational',
];
}
return $changes;
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @return list<string>
*/
private function diagnosticFieldUnion(array $before, array $after, string $key): array
{
$fields = [
...$this->diagnosticFieldList($before, $key),
...$this->diagnosticFieldList($after, $key),
];
$fields = array_values(array_unique($fields));
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
return $fields;
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function diagnosticFieldList(array $payload, string $key): array
{
$fields = data_get($payload, 'diagnostics.'.$key, []);
if (! is_array($fields)) {
return [];
}
return array_values(array_filter(
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
static fn (string $field): bool => $field !== '',
));
}
}