TenantAtlas/app/Services/Intune/RestoreRiskChecker.php
ahmido d120ed7c92 feat: endpoint security restore execution (023) (#25)
Added a resolver/validation flow that fetches endpoint security template definitions and enforces them before CREATE/PATCH so we don’t call Graph with invalid settings.
Hardened restore endpoint resolution (built-in fallback to deviceManagement/configurationPolicies, clearer error metadata, preview-only fallback when metadata is missing) and exposed Graph path/method in restore UI details.
Stripped read-only fields when PATCHing endpointSecurityIntent so the request no longer fails with “properties not patchable”.
Added regression tests covering endpoint security restore, intent sanitization, unknown type safety, Graph error metadata, and endpoint resolution behavior.
Testing

GraphClientEndpointResolutionTest.php
./vendor/bin/pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #25
2026-01-03 22:44:08 +00:00

792 lines
26 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,
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
) {}
/**
* @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->checkMetadataOnlySnapshots($policyItems);
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
$results[] = $this->checkEndpointSecurityTemplates($tenant, $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,
],
];
}
/**
* Validate that Endpoint Security policy templates referenced by snapshots exist in the tenant.
*
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkEndpointSecurityTemplates(Tenant $tenant, Collection $policyItems): ?array
{
$issues = [];
$hasRestoreEnabled = false;
$graphOptions = $tenant->graphOptions();
foreach ($policyItems as $item) {
if ($item->policy_type !== 'endpointSecurityPolicy') {
continue;
}
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode !== 'preview-only') {
$hasRestoreEnabled = true;
}
$payload = is_array($item->payload) ? $item->payload : [];
$templateReference = $payload['templateReference'] ?? null;
if (is_string($templateReference)) {
$decoded = json_decode($templateReference, true);
$templateReference = is_array($decoded) ? $decoded : null;
}
if (! is_array($templateReference)) {
$issues[] = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'label' => $item->resolvedDisplayName(),
'reason' => 'Missing templateReference in snapshot.',
];
continue;
}
$outcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions);
if (! ($outcome['success'] ?? false)) {
$issues[] = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'label' => $item->resolvedDisplayName(),
'template_id' => $templateReference['templateId'] ?? null,
'template_family' => $templateReference['templateFamily'] ?? null,
'reason' => $outcome['reason'] ?? 'Template could not be resolved in the tenant.',
];
}
}
if ($issues === []) {
return [
'code' => 'endpoint_security_templates',
'severity' => 'safe',
'title' => 'Endpoint security templates',
'message' => 'All referenced Endpoint Security templates are available.',
'meta' => [
'count' => 0,
],
];
}
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
$message = $hasRestoreEnabled
? 'Some Endpoint Security templates are missing or cannot be resolved in the tenant.'
: 'Some Endpoint Security templates are missing or cannot be resolved (execution is preview-only).';
return [
'code' => 'endpoint_security_templates',
'severity' => $severity,
'title' => 'Endpoint security templates',
'message' => $message,
'meta' => [
'count' => count($issues),
'items' => $this->truncateList($issues, 10),
],
];
}
/**
* 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<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|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<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);
if ($meta === []) {
return 'preview-only';
}
$restore = $meta['restore'] ?? 'enabled';
if (! is_string($restore) || $restore === '') {
return 'enabled';
}
return $restore;
}
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);
}
}