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
609 lines
19 KiB
PHP
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);
|
|
}
|
|
}
|