*/ public function buildSettingsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array { $policyType = $currentVersion?->policy_type ?? $baselineVersion?->policy_type ?? ''; $platform = $currentVersion?->platform ?? $baselineVersion?->platform; $from = $baselineVersion ? $this->settingsNormalizer->normalizeForDiff(is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [], (string) $policyType, $platform) : []; $to = $currentVersion ? $this->settingsNormalizer->normalizeForDiff(is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [], (string) $policyType, $platform) : []; $result = $this->versionDiff->compare($from, $to); $result['policy_type'] = $policyType; $protectedChanges = $this->protectedPointerChanges($baselineVersion, $currentVersion, 'snapshot'); if ($protectedChanges !== []) { $existingChanged = is_array($result['changed'] ?? null) ? $result['changed'] : []; foreach ($protectedChanges as $pointer) { $existingChanged['Protected > '.$pointer.' (value changed)'] = [ 'from' => '[REDACTED]', 'to' => '[REDACTED]', ]; } $result['changed'] = $existingChanged; $result['summary']['changed'] = count($existingChanged); $result['summary']['message'] = sprintf( '%d added, %d removed, %d changed (%d protected value change%s)', count(is_array($result['added'] ?? null) ? $result['added'] : []), count(is_array($result['removed'] ?? null) ? $result['removed'] : []), count($existingChanged), count($protectedChanges), count($protectedChanges) === 1 ? '' : 's', ); } return $result; } /** * @return array */ public function buildAssignmentsDiff(Tenant $tenant, ?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, int $limit = 200): array { $baseline = $baselineVersion ? $this->assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments) : []; $current = $currentVersion ? $this->assignmentsNormalizer->normalizeForDiff($currentVersion->assignments) : []; $baselineMap = []; foreach ($baseline as $row) { $baselineMap[$row['key']] = $row; } $currentMap = []; foreach ($current as $row) { $currentMap[$row['key']] = $row; } $allKeys = array_values(array_unique(array_merge(array_keys($baselineMap), array_keys($currentMap)))); sort($allKeys); $added = []; $removed = []; $changed = []; foreach ($allKeys as $key) { $from = $baselineMap[$key] ?? null; $to = $currentMap[$key] ?? null; if ($from === null && is_array($to)) { $added[] = $to; continue; } if ($to === null && is_array($from)) { $removed[] = $from; continue; } if (! is_array($from) || ! is_array($to)) { continue; } $diffFields = [ 'filter_type', 'filter_id', 'intent', 'mode', ]; $fieldChanges = []; foreach ($diffFields as $field) { $fromValue = $from[$field] ?? null; $toValue = $to[$field] ?? null; if ($fromValue !== $toValue) { $fieldChanges[$field] = [ 'from' => $fromValue, 'to' => $toValue, ]; } } if ($fieldChanges !== []) { $changed[] = [ 'key' => $key, 'include_exclude' => $to['include_exclude'], 'target_type' => $to['target_type'], 'target_id' => $to['target_id'], 'from' => $from, 'to' => $to, 'changes' => $fieldChanges, ]; } } $truncated = false; $total = count($added) + count($removed) + count($changed); if ($total > $limit) { $truncated = true; $budget = $limit; $changed = array_slice($changed, 0, min(count($changed), $budget)); $budget -= count($changed); $added = array_slice($added, 0, min(count($added), $budget)); $budget -= count($added); $removed = array_slice($removed, 0, min(count($removed), $budget)); } $groupDescriptions = $this->groupDescriptionsForDiff($tenant, $added, $removed, $changed); $decorateAssignment = function (array $row) use ($groupDescriptions, $tenant): array { $row['target_reference'] = $this->targetReference($tenant, $row, $groupDescriptions); $row['target_label'] = (string) data_get($row, 'target_reference.primaryLabel', 'Unknown target'); return $row; }; $decorateChanged = function (array $row) use ($decorateAssignment): array { $row['from'] = is_array($row['from'] ?? null) ? $decorateAssignment($row['from']) : $row['from']; $row['to'] = is_array($row['to'] ?? null) ? $decorateAssignment($row['to']) : $row['to']; $row['target_label'] = is_array($row['to'] ?? null) ? ($row['to']['target_label'] ?? null) : null; return $row; }; return [ 'summary' => [ 'added' => count($added), 'removed' => count($removed), 'changed' => count($changed), 'message' => $this->assignmentSummaryMessage($baselineVersion, $currentVersion, count($added), count($removed), count($changed)), 'truncated' => $truncated, 'limit' => $limit, ], 'added' => array_map($decorateAssignment, $added), 'removed' => array_map($decorateAssignment, $removed), 'changed' => array_map($decorateChanged, $changed), ]; } /** * @return array */ public function buildScopeTagsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array { $baselineIds = $baselineVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags) ?? []) : []; $currentIds = $currentVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags) ?? []) : []; $baselineLabels = $baselineVersion ? $this->scopeTagsNormalizer->labelsById($baselineVersion->scope_tags) : []; $currentLabels = $currentVersion ? $this->scopeTagsNormalizer->labelsById($currentVersion->scope_tags) : []; $baselineSet = array_fill_keys($baselineIds, true); $currentSet = array_fill_keys($currentIds, true); $addedIds = array_values(array_diff($currentIds, $baselineIds)); $removedIds = array_values(array_diff($baselineIds, $currentIds)); sort($addedIds); sort($removedIds); $decorate = static function (array $ids, array $labels): array { $rows = []; foreach ($ids as $id) { if (! is_string($id) || $id === '') { continue; } $rows[] = [ 'id' => $id, 'name' => $labels[$id] ?? ($id === '0' ? 'Default' : $id), ]; } return $rows; }; $protectedChanges = $this->protectedPointerChanges($baselineVersion, $currentVersion, 'scope_tags'); return [ 'summary' => [ 'added' => count($addedIds), 'removed' => count($removedIds), 'changed' => 0, 'message' => $protectedChanges === [] ? sprintf('%d added, %d removed', count($addedIds), count($removedIds)) : sprintf( '%d added, %d removed (%d protected value change%s)', count($addedIds), count($removedIds), count($protectedChanges), count($protectedChanges) === 1 ? '' : 's', ), 'baseline_count' => count($baselineSet), 'current_count' => count($currentSet), ], 'added' => $decorate($addedIds, $currentLabels), 'removed' => $decorate($removedIds, $baselineLabels), 'baseline' => $decorate($baselineIds, $baselineLabels), 'current' => $decorate($currentIds, $currentLabels), 'changed' => [], ]; } private function assignmentSummaryMessage(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, int $added, int $removed, int $changed): string { $protectedChanges = $this->protectedPointerChanges($baselineVersion, $currentVersion, 'assignments'); if ($protectedChanges === []) { return sprintf('%d added, %d removed, %d changed', $added, $removed, $changed); } return sprintf( '%d added, %d removed, %d changed (%d protected value change%s)', $added, $removed, $changed, count($protectedChanges), count($protectedChanges) === 1 ? '' : 's', ); } /** * @return array */ private function protectedPointerChanges(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, string $bucket): array { $baseline = $this->fingerprintBucket($baselineVersion, $bucket); $current = $this->fingerprintBucket($currentVersion, $bucket); $pointers = array_values(array_unique(array_merge(array_keys($baseline), array_keys($current)))); sort($pointers); return array_values(array_filter($pointers, static function (string $pointer) use ($baseline, $current): bool { return ($baseline[$pointer] ?? null) !== ($current[$pointer] ?? null); })); } /** * @return array */ private function fingerprintBucket(?PolicyVersion $version, string $bucket): array { if (! $version instanceof PolicyVersion || ! is_array($version->secret_fingerprints)) { return []; } $bucketFingerprints = $version->secret_fingerprints[$bucket] ?? []; return is_array($bucketFingerprints) ? $bucketFingerprints : []; } /** * @param array> $added * @param array> $removed * @param array> $changed * @return array */ private function groupDescriptionsForDiff(Tenant $tenant, array $added, array $removed, array $changed): array { $groupIds = []; foreach ([$added, $removed] as $items) { foreach ($items as $row) { $targetType = $row['target_type'] ?? null; $targetId = $row['target_id'] ?? null; if (! is_string($targetType) || ! is_string($targetId)) { continue; } if (! str_contains($targetType, 'groupassignmenttarget')) { continue; } $groupIds[] = $targetId; } } foreach ($changed as $row) { $targetType = $row['target_type'] ?? null; $targetId = $row['target_id'] ?? null; if (! is_string($targetType) || ! is_string($targetId)) { continue; } if (! str_contains($targetType, 'groupassignmenttarget')) { continue; } $groupIds[] = $targetId; } $groupIds = array_values(array_unique($groupIds)); if ($groupIds === []) { return []; } return $this->groupLabelResolver->describeMany($tenant, $groupIds); } /** * @param array $assignment * @param array $groupDescriptions * @return array */ private function targetReference(Tenant $tenant, array $assignment, array $groupDescriptions): array { $targetType = is_string($assignment['target_type'] ?? null) ? (string) $assignment['target_type'] : ''; $targetId = is_string($assignment['target_id'] ?? null) ? (string) $assignment['target_id'] : ''; return $this->resolvedReferencePresenter->present( $this->assignmentTargetReferenceResolver->resolve([], [ 'tenant_id' => (int) $tenant->getKey(), 'target_type' => $targetType, 'target_id' => $targetId, 'group_descriptions' => $groupDescriptions, ]), ReferencePresentationVariant::Compact, ); } }