TenantAtlas/apps/platform/app/Services/TenantConfiguration/SecurityComplianceCoverageComparator.php
Ahmed Darrazi c49acba7cd
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m20s
feat: complete spec 423 security compliance readiness pack
2026-06-30 13:57:10 +02:00

298 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class SecurityComplianceCoverageComparator
{
/**
* @var list<string>
*/
private const MATERIAL_CHANGE_TYPES = [
'added',
'removed',
'changed',
'manual_review_required',
];
public function __construct(
private readonly SecurityComplianceComparablePayloadNormalizer $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),
canonicalType: $canonicalType,
),
];
$hasMaterialChange = collect($changes)
->contains(fn (array $change): bool => in_array($change['classification'] ?? null, self::MATERIAL_CHANGE_TYPES, true));
$manualReview = collect($changes)
->contains(fn (array $change): bool => ($change['classification'] ?? null) === 'manual_review_required'
|| in_array($change['importance'] ?? null, ['critical', 'important'], true));
return [
'canonical_type' => $canonicalType,
'supported' => true,
'classification' => $manualReview ? 'manual_review_required' : ($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 = '', string $canonicalType = ''): 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, $canonicalType) as $nestedChange) {
$changes[] = $nestedChange;
}
continue;
}
if ($this->containsRedacted($beforeValue) || $this->containsRedacted($afterValue)) {
continue;
}
if ($beforeValue === $afterValue) {
continue;
}
$changes[] = [
'field' => $field,
'classification' => $this->changeClassification($beforeValue, $afterValue),
'importance' => $this->importance($canonicalType, $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 containsRedacted(mixed $value): bool
{
if ($value === '[redacted]') {
return true;
}
if (! is_array($value)) {
return false;
}
foreach ($value as $nestedValue) {
if ($this->containsRedacted($nestedValue)) {
return true;
}
}
return false;
}
private function importance(string $canonicalType, string $field): string
{
if ($canonicalType === 'retentionCompliancePolicy'
&& (
$field === 'enabled_state'
|| str_starts_with($field, 'retention.')
|| str_starts_with($field, 'scope.')
)
) {
return 'critical';
}
if ($canonicalType === 'dlpCompliancePolicy'
&& (
in_array($field, ['mode', 'state', 'actions', 'rules'], true)
|| str_starts_with($field, 'scope.')
)
) {
return 'critical';
}
if ($canonicalType === 'labelPolicy'
&& (
str_starts_with($field, 'labeling.')
|| str_starts_with($field, 'scope.')
)
) {
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 = [];
$manualReviewFields = $this->diagnosticFieldUnion($before, $after, 'manual_review_fields');
foreach ($this->diagnosticFieldUnion($before, $after, 'unsupported_fields') as $field) {
if (in_array($field, $manualReviewFields, true)) {
continue;
}
$changes[] = [
'field' => $field,
'classification' => 'unsupported_field',
'importance' => 'informational',
];
}
foreach ($this->diagnosticFieldUnion($before, $after, 'redacted_fields') as $field) {
$changes[] = [
'field' => $field,
'classification' => 'redacted',
'importance' => 'informational',
];
}
foreach ($manualReviewFields as $field) {
$changes[] = [
'field' => $field,
'classification' => 'manual_review_required',
'importance' => 'manual_review_required',
];
}
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 !== '',
));
}
}