TenantAtlas/app/Services/Intune/FoundationSnapshotService.php
ahmido d2dbc52a32 feat(006): foundations + assignment mapping and preview-only restore guard (#7)
## Summary
- Capture and restore foundation types (assignment filters, scope tags, notification templates) with deterministic mapping.
- Apply foundation mappings during restore (scope tags on policy payloads, assignment filter mapping with skip reasons).
- Improve restore run UX (item selection, rerun action, preview-only badges).
- Enforce preview-only policy types (e.g. Conditional Access) during execution.

## Testing
- ./vendor/bin/sail artisan test tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php

## Notes
- Specs/plan/tasks updated under specs/006-sot-foundations-assignments.
- No migrations.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #7
2025-12-26 23:44:31 +00:00

122 lines
3.8 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
class FoundationSnapshotService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphContractRegistry $contracts,
) {}
/**
* @return array{items: array<int, array{source_id:string,display_name:?string,payload:array,metadata:array}>, failures: array<int, array{foundation_type:string,reason:string,status:int|string|null}>}
*/
public function fetchAll(Tenant $tenant, string $foundationType): array
{
$resource = $this->contracts->resourcePath($foundationType);
if (! $resource) {
return [
'items' => [],
'failures' => [[
'foundation_type' => $foundationType,
'reason' => 'Graph contract resource missing for foundation type.',
'status' => null,
]],
];
}
$contract = $this->contracts->get($foundationType);
$query = [];
if (! empty($contract['allowed_select']) && is_array($contract['allowed_select'])) {
$query['$select'] = $contract['allowed_select'];
}
$sanitized = $this->contracts->sanitizeQuery($foundationType, $query);
$options = $tenant->graphOptions();
$items = [];
$failures = [];
$nextPath = $resource;
$useQuery = $sanitized['query'] ?? [];
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, $options + [
'query' => $useQuery,
]);
if ($response->failed()) {
$failures[] = [
'foundation_type' => $foundationType,
'reason' => $response->meta['error_message'] ?? $response->warnings[0] ?? 'Graph request failed.',
'status' => $response->status,
];
break;
}
$data = $response->data;
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
foreach ($pageItems as $item) {
if (! is_array($item)) {
continue;
}
$sourceId = $item['id'] ?? null;
if (! is_string($sourceId) || $sourceId === '') {
continue;
}
$displayName = $item['displayName'] ?? $item['name'] ?? null;
$items[] = [
'source_id' => $sourceId,
'display_name' => is_string($displayName) ? $displayName : null,
'payload' => $item,
'metadata' => [
'displayName' => is_string($displayName) ? $displayName : null,
'kind' => $foundationType,
'graph' => [
'resource' => $resource,
'apiVersion' => config('graph.version', 'beta'),
],
],
];
}
$nextLink = $data['@odata.nextLink'] ?? null;
if (! $nextLink) {
break;
}
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
$useQuery = [];
}
return [
'items' => $items,
'failures' => $failures,
];
}
private function stripGraphBaseUrl(string $nextLink): string
{
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/')
.'/'.trim(config('graph.version', 'beta'), '/');
if (str_starts_with($nextLink, $base)) {
return ltrim(substr($nextLink, strlen($base)), '/');
}
return ltrim($nextLink, '/');
}
}