## Summary - add the shared resolved-reference foundation with registry, resolvers, presenters, and badge semantics - refactor related context, assignment evidence, and policy-version assignment rendering toward label-first reference presentation - add Spec 132 artifacts and focused Pest coverage for reference resolution, degraded states, canonical linking, and tenant-context carryover ## Verification - `vendor/bin/sail bin pint --dirty --format agent` - focused Pest verification was marked complete in the task artifact ## Notes - this PR is opened from the current session branch - `specs/132-guid-context-resolver/tasks.md` reflects in-progress completion state for the implemented tasks Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #161
386 lines
14 KiB
PHP
386 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Drift;
|
|
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Services\Directory\EntraGroupLabelResolver;
|
|
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
|
use App\Services\Intune\VersionDiff;
|
|
use App\Support\References\ReferencePresentationVariant;
|
|
use App\Support\References\ResolvedReferencePresenter;
|
|
use App\Support\References\Resolvers\AssignmentTargetReferenceResolver;
|
|
|
|
class DriftFindingDiffBuilder
|
|
{
|
|
public function __construct(
|
|
private readonly SettingsNormalizer $settingsNormalizer,
|
|
private readonly AssignmentsNormalizer $assignmentsNormalizer,
|
|
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
|
private readonly VersionDiff $versionDiff,
|
|
private readonly EntraGroupLabelResolver $groupLabelResolver,
|
|
private readonly AssignmentTargetReferenceResolver $assignmentTargetReferenceResolver,
|
|
private readonly ResolvedReferencePresenter $resolvedReferencePresenter,
|
|
) {}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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<string, string>
|
|
*/
|
|
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<int, array<string, mixed>> $added
|
|
* @param array<int, array<string, mixed>> $removed
|
|
* @param array<int, array<string, mixed>> $changed
|
|
* @return array<string, array{display_name: ?string, resolved: bool}>
|
|
*/
|
|
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<string, mixed> $assignment
|
|
* @param array<string, array{display_name: ?string, resolved: bool}> $groupDescriptions
|
|
* @return array<string, mixed>
|
|
*/
|
|
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,
|
|
);
|
|
}
|
|
}
|