|null $selectedItemIds * @param array $groupMapping * @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array}>} */ 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->checkMetadataOnlySnapshots($policyItems); $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|null $selectedItemIds * @return Collection */ 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 $policyItems * @param array $groupMapping * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|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, ], ]; } /** * Detect snapshots that were captured as metadata-only. * * These snapshots cannot be safely restored because they do not contain the * complete settings payload. * * @param Collection $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|null */ private function checkMetadataOnlySnapshots(Collection $policyItems): ?array { $affected = []; $hasRestoreEnabled = false; foreach ($policyItems as $item) { if (! $this->isMetadataOnlySnapshot($item)) { continue; } $restoreMode = $this->resolveRestoreMode($item->policy_type); if ($restoreMode !== 'preview-only') { $hasRestoreEnabled = true; } $affected[] = [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, 'policy_type' => $item->policy_type, 'label' => $item->resolvedDisplayName(), 'restore_mode' => $restoreMode, ]; } if ($affected === []) { return [ 'code' => 'metadata_only', 'severity' => 'safe', 'title' => 'Snapshot completeness', 'message' => 'No metadata-only snapshots detected.', 'meta' => [ 'count' => 0, ], ]; } $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; $message = $hasRestoreEnabled ? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.' : 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.'; return [ 'code' => 'metadata_only', 'severity' => $severity, 'title' => 'Snapshot completeness', 'message' => $message, 'meta' => [ 'count' => count($affected), 'items' => $this->truncateList($affected, 10), ], ]; } private function isMetadataOnlySnapshot(BackupItem $item): bool { $metadata = is_array($item->metadata) ? $item->metadata : []; $source = $metadata['source'] ?? null; $snapshotSource = $metadata['snapshot_source'] ?? null; if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') { return true; } $warnings = $metadata['warnings'] ?? null; if (is_array($warnings)) { foreach ($warnings as $warning) { if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) { return true; } } } return false; } /** * @param Collection $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $items * @param Collection $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems * @return array{0: array, 1: array} */ 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 */ 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> $items * @return array> */ private function truncateList(array $items, int $limit): array { if (count($items) <= $limit) { return $items; } return array_slice($items, 0, $limit); } }