TenantAtlas/app/Services/Intune/RestoreRiskChecker.php
ahmido b048131f81 feat/011-restore-run-wizard (#17)
Wichtige Änderungen:
   - Eine neue "Restore via Wizard"-Aktion wurde der PolicyVersion-Tabelle hinzugefügt.
   - Diese Aktion ermöglicht die Erstellung eines Einzelposten-BackupSets aus dem ausgewählten
     Policy-Version-Snapshot.
   - Der CreateRestoreRun Wizard unterstützt nun das Vorbefüllen seiner Formularfelder basierend auf
     Abfrageparametern, was eine nahtlose Übergabe von der PolicyVersion-Aktion ermöglicht.
   - Umfassende Feature-Tests wurden hinzugefügt, um die korrekte Funktionalität und Integration dieses
     neuen Workflows sicherzustellen.
   - Die specs/011-restore-run-wizard/tasks.md wurde aktualisiert, um den Abschluss von Aufgabe T023
     widerzuspiegeln.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #17
2025-12-31 19:14:59 +00:00

609 lines
19 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\GroupResolver;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class RestoreRiskChecker
{
public function __construct(
private readonly GroupResolver $groupResolver,
) {}
/**
* @param array<int>|null $selectedItemIds
* @param array<string, string|null> $groupMapping
* @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array<int, array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}>}
*/
public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null, array $groupMapping = []): array
{
if ($backupSet->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
}
$items = $this->loadItems($backupSet, $selectedItemIds);
$policyItems = $items
->reject(fn (BackupItem $item): bool => $item->isFoundation())
->values();
$results = [];
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
$results[] = $this->checkStalePolicies($tenant, $policyItems);
$results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null);
$results = array_values(array_filter($results));
$summary = [
'blocking' => 0,
'warning' => 0,
'safe' => 0,
'has_blockers' => false,
];
foreach ($results as $result) {
$severity = $result['severity'] ?? 'safe';
if (! in_array($severity, ['blocking', 'warning', 'safe'], true)) {
$severity = 'safe';
}
$summary[$severity]++;
}
$summary['has_blockers'] = $summary['blocking'] > 0;
return [
'summary' => $summary,
'results' => $results,
];
}
/**
* @param array<int>|null $selectedItemIds
* @return Collection<int, BackupItem>
*/
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
{
$query = $backupSet->items()->getQuery();
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
return $query->orderBy('id')->get();
}
/**
* @param Collection<int, BackupItem> $policyItems
* @param array<string, string|null> $groupMapping
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkOrphanedGroups(Tenant $tenant, Collection $policyItems, array $groupMapping): ?array
{
[$groupIds, $sourceNames] = $this->extractGroupIds($policyItems);
if ($groupIds === []) {
return [
'code' => 'assignment_groups',
'severity' => 'safe',
'title' => 'Assignments',
'message' => 'No group-based assignments detected.',
'meta' => [
'group_count' => 0,
],
];
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$resolved = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
$orphaned = [];
foreach ($groupIds as $groupId) {
$group = $resolved[$groupId] ?? null;
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
continue;
}
$orphaned[] = [
'id' => $groupId,
'label' => $this->formatGroupLabel($sourceNames[$groupId] ?? null, $groupId),
];
}
if ($orphaned === []) {
return [
'code' => 'assignment_groups',
'severity' => 'safe',
'title' => 'Assignments',
'message' => sprintf('%d group assignment targets resolved.', count($groupIds)),
'meta' => [
'group_count' => count($groupIds),
'orphaned_count' => 0,
],
];
}
$unmapped = [];
$mapped = [];
$skipped = [];
foreach ($orphaned as $group) {
$groupId = $group['id'];
$mapping = $groupMapping[$groupId] ?? null;
if (! is_string($mapping) || $mapping === '') {
$unmapped[] = $group;
continue;
}
if ($mapping === 'SKIP') {
$skipped[] = $group;
continue;
}
$mapped[] = $group + [
'mapped_to' => $mapping,
];
}
$severity = $unmapped !== [] ? 'blocking' : 'warning';
$message = $unmapped !== []
? sprintf('%d group assignment targets are missing in the tenant and require mapping (or skip).', count($unmapped))
: sprintf('%d group assignment targets are missing in the tenant (mapped/skipped).', count($orphaned));
return [
'code' => 'assignment_groups',
'severity' => $severity,
'title' => 'Assignments',
'message' => $message,
'meta' => [
'group_count' => count($groupIds),
'orphaned_count' => count($orphaned),
'unmapped' => $unmapped,
'mapped' => $mapped,
'skipped' => $skipped,
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
{
$byType = [];
foreach ($policyItems as $item) {
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode !== 'preview-only') {
continue;
}
$label = $this->resolveTypeLabel($item->policy_type);
$byType[$label] ??= 0;
$byType[$label]++;
}
if ($byType === []) {
return [
'code' => 'preview_only',
'severity' => 'safe',
'title' => 'Preview-only types',
'message' => 'No preview-only policy types detected.',
'meta' => [
'count' => 0,
],
];
}
return [
'code' => 'preview_only',
'severity' => 'warning',
'title' => 'Preview-only types',
'message' => 'Some selected items are preview-only and will never execute.',
'meta' => [
'count' => array_sum($byType),
'types' => $byType,
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkMissingPolicies(Tenant $tenant, Collection $policyItems): ?array
{
$pairs = [];
foreach ($policyItems as $item) {
$identifier = $item->policy_identifier;
$type = $item->policy_type;
if (! is_string($identifier) || $identifier === '' || ! is_string($type) || $type === '') {
continue;
}
$pairs[] = [
'identifier' => $identifier,
'type' => $type,
'label' => $item->resolvedDisplayName(),
];
}
if ($pairs === []) {
return [
'code' => 'missing_policies',
'severity' => 'safe',
'title' => 'Target policies',
'message' => 'No policy identifiers available to verify.',
'meta' => [
'missing_count' => 0,
],
];
}
$identifiers = array_values(array_unique(array_column($pairs, 'identifier')));
$types = array_values(array_unique(array_column($pairs, 'type')));
$existing = Policy::query()
->where('tenant_id', $tenant->id)
->whereIn('external_id', $identifiers)
->whereIn('policy_type', $types)
->get(['id', 'external_id', 'policy_type'])
->mapWithKeys(fn (Policy $policy) => [$this->policyKey($policy->policy_type, $policy->external_id) => $policy->id])
->all();
$missing = [];
foreach ($pairs as $pair) {
$key = $this->policyKey($pair['type'], $pair['identifier']);
if (array_key_exists($key, $existing)) {
continue;
}
$missing[] = [
'type' => $pair['type'],
'identifier' => $pair['identifier'],
'label' => $pair['label'],
];
}
$missing = array_values(collect($missing)->unique(fn (array $row) => $this->policyKey($row['type'], $row['identifier']))->all());
if ($missing === []) {
return [
'code' => 'missing_policies',
'severity' => 'safe',
'title' => 'Target policies',
'message' => 'All policies exist in the tenant (restore will update).',
'meta' => [
'missing_count' => 0,
],
];
}
return [
'code' => 'missing_policies',
'severity' => 'warning',
'title' => 'Target policies',
'message' => sprintf('%d policies do not exist in the tenant and will be created.', count($missing)),
'meta' => [
'missing_count' => count($missing),
'missing' => $this->truncateList($missing, 10),
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkStalePolicies(Tenant $tenant, Collection $policyItems): ?array
{
$itemsByPolicyId = [];
foreach ($policyItems as $item) {
if (! $item->policy_id) {
continue;
}
$capturedAt = $item->captured_at;
if (! $capturedAt) {
continue;
}
$itemsByPolicyId[$item->policy_id][] = [
'backup_item_id' => $item->id,
'captured_at' => $capturedAt,
'label' => $item->resolvedDisplayName(),
];
}
if ($itemsByPolicyId === []) {
return [
'code' => 'stale_policies',
'severity' => 'safe',
'title' => 'Staleness',
'message' => 'No captured timestamps available to evaluate staleness.',
'meta' => [
'stale_count' => 0,
],
];
}
$latestVersions = PolicyVersion::query()
->where('tenant_id', $tenant->id)
->whereIn('policy_id', array_keys($itemsByPolicyId))
->selectRaw('policy_id, max(captured_at) as latest_captured_at')
->groupBy('policy_id')
->get()
->mapWithKeys(function (PolicyVersion $version) {
$latestCapturedAt = $version->getAttribute('latest_captured_at');
if (is_string($latestCapturedAt) && $latestCapturedAt !== '') {
$latestCapturedAt = CarbonImmutable::parse($latestCapturedAt);
} else {
$latestCapturedAt = null;
}
return [
(int) $version->policy_id => $latestCapturedAt,
];
})
->all();
$stale = [];
foreach ($itemsByPolicyId as $policyId => $policyItems) {
$latestCapturedAt = $latestVersions[(int) $policyId] ?? null;
if (! $latestCapturedAt) {
continue;
}
foreach ($policyItems as $policyItem) {
if ($latestCapturedAt->greaterThan($policyItem['captured_at'])) {
$stale[] = [
'backup_item_id' => $policyItem['backup_item_id'],
'label' => $policyItem['label'],
'snapshot_captured_at' => $policyItem['captured_at']->toIso8601String(),
'latest_captured_at' => $latestCapturedAt->toIso8601String(),
];
}
}
}
if ($stale === []) {
return [
'code' => 'stale_policies',
'severity' => 'safe',
'title' => 'Staleness',
'message' => 'No newer versions detected since the snapshot.',
'meta' => [
'stale_count' => 0,
],
];
}
return [
'code' => 'stale_policies',
'severity' => 'warning',
'title' => 'Staleness',
'message' => sprintf('%d policies have newer versions in the tenant than this snapshot.', count($stale)),
'meta' => [
'stale_count' => count($stale),
'stale' => $this->truncateList($stale, 10),
],
];
}
/**
* @param Collection<int, BackupItem> $items
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkMissingScopeTagsInScope(Collection $items, Collection $policyItems, bool $isSelectedScope): ?array
{
if (! $isSelectedScope) {
return [
'code' => 'scope_tags_in_scope',
'severity' => 'safe',
'title' => 'Scope tags',
'message' => 'Scope includes all items; foundations are available if present in the backup set.',
'meta' => [
'missing_scope_tags' => false,
],
];
}
$selectedScopeTagCount = $items->where('policy_type', 'roleScopeTag')->count();
$scopeTagIds = [];
foreach ($policyItems as $item) {
$ids = $item->scope_tag_ids;
if (! is_array($ids)) {
continue;
}
foreach ($ids as $id) {
if (! is_string($id) || $id === '' || $id === '0') {
continue;
}
$scopeTagIds[] = $id;
}
}
$scopeTagIds = array_values(array_unique($scopeTagIds));
if ($scopeTagIds === [] || $selectedScopeTagCount > 0) {
return [
'code' => 'scope_tags_in_scope',
'severity' => 'safe',
'title' => 'Scope tags',
'message' => 'Scope tags look OK for the selected items.',
'meta' => [
'missing_scope_tags' => false,
'referenced_scope_tags' => count($scopeTagIds),
'selected_scope_tag_items' => $selectedScopeTagCount,
],
];
}
return [
'code' => 'scope_tags_in_scope',
'severity' => 'warning',
'title' => 'Scope tags',
'message' => 'Policies reference scope tags, but scope tags are not included in the selected restore scope.',
'meta' => [
'missing_scope_tags' => true,
'referenced_scope_tags' => count($scopeTagIds),
'selected_scope_tag_items' => 0,
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{0: array<int, string>, 1: array<string, string>}
*/
private function extractGroupIds(Collection $policyItems): array
{
$groupIds = [];
$sourceNames = [];
foreach ($policyItems as $item) {
if (! is_array($item->assignments) || $item->assignments === []) {
continue;
}
foreach ($item->assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (! in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true)) {
continue;
}
$groupId = $target['groupId'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$groupIds[] = $groupId;
$displayName = $target['group_display_name'] ?? null;
if (is_string($displayName) && $displayName !== '') {
$sourceNames[$groupId] = $displayName;
}
}
}
$groupIds = array_values(array_unique($groupIds));
return [$groupIds, $sourceNames];
}
private function formatGroupLabel(?string $name, string $id): string
{
$parts = [];
if (is_string($name) && $name !== '') {
$parts[] = $name;
}
$parts[] = Str::limit($id, 24, '...');
return implode(' • ', $parts);
}
private function policyKey(string $type, string $identifier): string
{
return $type.'|'.$identifier;
}
/**
* @return array<string, mixed>
*/
private function resolveTypeMeta(?string $type): array
{
if (! is_string($type) || $type === '') {
return [];
}
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
foreach ($types as $typeConfig) {
if (($typeConfig['type'] ?? null) === $type) {
return is_array($typeConfig) ? $typeConfig : [];
}
}
return [];
}
private function resolveRestoreMode(?string $policyType): string
{
$meta = $this->resolveTypeMeta($policyType);
return (string) ($meta['restore'] ?? 'enabled');
}
private function resolveTypeLabel(?string $policyType): string
{
$meta = $this->resolveTypeMeta($policyType);
return (string) ($meta['label'] ?? $policyType ?? 'Unknown');
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
private function truncateList(array $items, int $limit): array
{
if (count($items) <= $limit) {
return $items;
}
return array_slice($items, 0, $limit);
}
}