TenantAtlas/app/Services/Drift/DriftFindingDiffBuilder.php
ahmido a449ecec5b feat/044-drift-mvp (#58)
Beschreibung
Implementiert das Drift MVP Feature (Spec: 044-drift-mvp) mit Fokus auf automatische Drift-Erkennung zwischen Inventory Sync Runs und Bulk-Triage für Findings.

Was wurde implementiert?
Drift-Erkennung: Vergleicht Policy-Snapshots, Assignments und Scope Tags zwischen Baseline- und Current-Runs. Deterministische Fingerprints verhindern Duplikate.
Findings UI: Neue Filament Resource für Findings mit Listen- und Detail-Ansicht. DB-only Diffs (keine Graph-Calls zur Laufzeit).
Bulk Acknowledge:
"Acknowledge selected" (Bulk-Action auf der Liste)
"Acknowledge all matching" (Header-Action, respektiert aktuelle Filter; Type-to-Confirm bei >100 Findings)
Scope Tag Fix: Behebt False Positives bei Legacy-Daten ohne scope_tags.ids (inferiert Default-Werte).
Authorization: Tenant-isoliert, Rollen-basiert (Owner/Manager/Operator können acknowledge).
Tests: Vollständige Pest-Coverage (28 Tests, 347 Assertions) für Drift-Logik, UI und Bulk-Actions.
Warum diese Änderungen?
Problem: Keine automatisierte Drift-Erkennung; manuelle Triage bei vielen Findings ist mühsam.
Lösung: Async Drift-Generierung mit persistenter Findings-Tabelle. Safe Bulk-Tools für Massen-Triage ohne Deletes.
Konformität: Folgt AGENTS.md Workflow, Spec-Kit (Tasks + Checklists abgehakt), Laravel/Filament Best Practices.
Technische Details
Neue Dateien: ~40 (Models, Services, Tests, Views, Migrations)
Änderungen: Filament Resources, Jobs, Policies
DB: Neue findings Tabelle (JSONB für Evidence, Indexes für Performance)
Tests: ./vendor/bin/sail artisan test tests/Feature/Drift --parallel → 28 passed
Migration: ./vendor/bin/sail artisan migrate (neue Tabelle + Indexes)
Screenshots / Links
Spec: spec.md
Tasks: tasks.md (alle abgehakt)
UI: Findings-Liste mit Bulk-Actions; Detail-View mit Diffs
Checklist
 Tests passieren (parallel + serial)
 Code formatiert (./vendor/bin/pint --dirty)
 Migration reversibel
 Tenant-Isolation enforced
 No Graph-Calls in Views
 Authorization checks
 Spec + Tasks aligned
Deployment Notes
Neue Migration: create_findings_table
Neue Permissions: drift.view, drift.acknowledge
Queue-Job: GenerateDriftFindingsJob (async, deduped)
2026-01-14 23:16:10 +00:00

305 lines
10 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;
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,
) {}
/**
* @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;
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));
}
$labels = $this->groupLabelsForDiff($tenant, $added, $removed, $changed);
$decorateAssignment = function (array $row) use ($labels): array {
$row['target_label'] = $this->targetLabel($row, $labels);
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' => sprintf('%d added, %d removed, %d changed', 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;
};
return [
'summary' => [
'added' => count($addedIds),
'removed' => count($removedIds),
'changed' => 0,
'message' => sprintf('%d added, %d removed', count($addedIds), count($removedIds)),
'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' => [],
];
}
/**
* @param array<int, array<string, mixed>> $added
* @param array<int, array<string, mixed>> $removed
* @param array<int, array<string, mixed>> $changed
* @return array<string, string>
*/
private function groupLabelsForDiff(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->resolveMany($tenant, $groupIds);
}
/**
* @param array<string, mixed> $assignment
* @param array<string, string> $groupLabels
*/
private function targetLabel(array $assignment, array $groupLabels): string
{
$targetType = $assignment['target_type'] ?? null;
$targetId = $assignment['target_id'] ?? null;
if (! is_string($targetType) || ! is_string($targetId)) {
return 'Unknown target';
}
if (str_contains($targetType, 'alldevicesassignmenttarget')) {
return 'All devices';
}
if (str_contains($targetType, 'allusersassignmenttarget')) {
return 'All users';
}
if (str_contains($targetType, 'groupassignmenttarget')) {
return $groupLabels[$targetId] ?? EntraGroupLabelResolver::formatLabel(null, $targetId);
}
return sprintf('%s (%s)', $targetType, $targetId);
}
}