218 lines
7.6 KiB
PHP
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, '/');
|
|
}
|
|
}
|