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)
305 lines
10 KiB
PHP
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);
|
|
}
|
|
}
|