TenantAtlas/app/Services/Intune/PolicySnapshotService.php
2025-12-14 20:23:18 +01:00

218 lines
7.6 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use Illuminate\Support\Arr;
use Throwable;
class PolicySnapshotService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphLogger $graphLogger,
private readonly GraphContractRegistry $contracts,
private readonly SnapshotValidator $snapshotValidator,
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
) {}
/**
* Fetch a policy snapshot from Graph (with optional hydration) for backup/version capture.
*
* @return array{payload:array,metadata:array,warnings:array}|array{failure:array}
*/
public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null): array
{
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$context = [
'tenant' => $tenantIdentifier,
'policy_type' => $policy->policy_type,
'policy_id' => $policy->external_id,
];
$this->graphLogger->logRequest('get_policy', $context);
try {
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $policy->platform,
]);
} catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
return [
'failure' => [
'policy_id' => $policy->id,
'reason' => $mapped->getMessage(),
'status' => $mapped->status,
],
];
}
$this->graphLogger->logResponse('get_policy', $response, $context);
$payload = $response->data['payload'] ?? $response->data;
$metadata = Arr::except($response->data, ['payload']);
$metadataWarnings = $metadata['warnings'] ?? [];
if ($policy->policy_type === 'settingsCatalogPolicy') {
[$payload, $metadata] = $this->hydrateSettingsCatalog(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($response->failed()) {
$reason = $response->warnings[0] ?? 'Graph request failed';
$failure = [
'policy_id' => $policy->id,
'reason' => $reason,
'status' => $response->status,
];
if (! config('graph.stub_on_failure')) {
return ['failure' => $failure];
}
$payload = [
'id' => $policy->external_id,
'type' => $policy->policy_type,
'source' => 'stub',
'warning' => $reason,
];
$metadataWarnings = $response->warnings ?? [$reason];
}
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
$odataWarning = Policy::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform);
if ($odataWarning) {
$metadataWarnings[] = $odataWarning;
}
if (! empty($metadataWarnings)) {
$metadata['warnings'] = array_values(array_unique($metadataWarnings));
}
return [
'payload' => is_array($payload) ? $payload : [],
'metadata' => $metadata,
'warnings' => $metadataWarnings,
];
}
/**
* Hydrate settings catalog policies with configuration settings subresource.
*
* @return array{0:array,1:array}
*/
private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy');
$settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId);
if ($strategy !== 'subresource_settings' || ! $settingsPath) {
return [$payload, $metadata];
}
$settings = [];
$nextPath = $settingsPath;
$hydrationStatus = 'complete';
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed()) {
$hydrationStatus = 'failed';
break;
}
$data = $response->data;
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
$settings = array_merge($settings, $pageItems);
$nextLink = $data['@odata.nextLink'] ?? null;
if (! $nextLink) {
break;
}
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
}
if (! empty($settings)) {
$payload['settings'] = $settings;
// Extract definition IDs and warm cache (T008-T010)
$definitionIds = $this->extractDefinitionIds($settings);
$metadata['definition_count'] = count($definitionIds);
// Warm cache for definitions (non-blocking)
$this->definitionResolver->warmCache($definitionIds);
$metadata['definitions_cached'] = true;
}
$metadata['settings_hydration'] = $hydrationStatus;
return [$payload, $metadata];
}
/**
* Extract all settingDefinitionId from settings array, including nested children.
*/
private function extractDefinitionIds(array $settings): array
{
$definitionIds = [];
foreach ($settings as $setting) {
// Extract definition ID from settingInstance
if (isset($setting['settingInstance']['settingDefinitionId'])) {
$definitionIds[] = $setting['settingInstance']['settingDefinitionId'];
}
// Handle groupSettingCollectionInstance with children
if (isset($setting['settingInstance']['@odata.type']) &&
str_contains($setting['settingInstance']['@odata.type'], 'groupSettingCollectionInstance')) {
if (isset($setting['settingInstance']['groupSettingCollectionValue'])) {
foreach ($setting['settingInstance']['groupSettingCollectionValue'] as $group) {
if (isset($group['children'])) {
$childIds = $this->extractDefinitionIds($group['children']);
$definitionIds = array_merge($definitionIds, $childIds);
}
}
}
}
}
return array_unique($definitionIds);
}
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, '/');
}
}