TenantAtlas/app/Services/Intune/ConfigurationPolicyTemplateResolver.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

389 lines
13 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use Illuminate\Support\Arr;
class ConfigurationPolicyTemplateResolver
{
/**
* @var array<string, array<string, array{success:bool,template:?array,reason:?string}>>
*/
private array $templateCache = [];
/**
* @var array<string, array<string, array{success:bool,templates:array<int,array>,reason:?string}>>
*/
private array $familyCache = [];
/**
* @var array<string, array<string, array{success:bool,definition_ids:array<int,string>,reason:?string}>>
*/
private array $templateDefinitionCache = [];
public function __construct(
private readonly GraphClientInterface $graphClient,
) {}
/**
* @param array<string, mixed> $templateReference
* @param array<string, mixed> $graphOptions
* @return array{success:bool,template_id:?string,template_reference:?array,reason:?string,warnings:array<int,string>}
*/
public function resolveTemplateReference(Tenant $tenant, array $templateReference, array $graphOptions = []): array
{
$warnings = [];
$templateId = $this->extractString($templateReference, ['templateId', 'TemplateId']);
$templateFamily = $this->extractString($templateReference, ['templateFamily', 'TemplateFamily']);
$templateDisplayName = $this->extractString($templateReference, ['templateDisplayName', 'TemplateDisplayName']);
$templateDisplayVersion = $this->extractString($templateReference, ['templateDisplayVersion', 'TemplateDisplayVersion']);
if ($templateId !== null) {
$templateOutcome = $this->getTemplate($tenant, $templateId, $graphOptions);
if ($templateOutcome['success']) {
return [
'success' => true,
'template_id' => $templateId,
'template_reference' => $templateReference,
'reason' => null,
'warnings' => $warnings,
];
}
if ($templateFamily === null) {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => $templateOutcome['reason'] ?? "Template '{$templateId}' is not available in the tenant.",
'warnings' => $warnings,
];
}
}
if ($templateFamily === null) {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => 'Template reference is missing templateFamily and cannot be resolved.',
'warnings' => $warnings,
];
}
$listOutcome = $this->listTemplatesByFamily($tenant, $templateFamily, $graphOptions);
if (! $listOutcome['success']) {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => $listOutcome['reason'] ?? "Unable to list templates for family '{$templateFamily}'.",
'warnings' => $warnings,
];
}
$candidates = $this->chooseTemplateCandidate(
templates: $listOutcome['templates'],
templateDisplayName: $templateDisplayName,
templateDisplayVersion: $templateDisplayVersion,
);
if (count($candidates) !== 1) {
$reason = count($candidates) === 0
? "No templates found for family '{$templateFamily}'."
: "Multiple templates found for family '{$templateFamily}' (cannot resolve automatically).";
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => $reason,
'warnings' => $warnings,
];
}
$candidate = $candidates[0];
$resolvedId = is_array($candidate) ? ($candidate['id'] ?? null) : null;
if (! is_string($resolvedId) || $resolvedId === '') {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => "Template candidate for family '{$templateFamily}' is missing an id.",
'warnings' => $warnings,
];
}
if ($templateId !== null && $templateId !== $resolvedId) {
$warnings[] = sprintf("TemplateId '%s' not found; mapped to '%s' via templateFamily.", $templateId, $resolvedId);
}
$templateReference['templateId'] = $resolvedId;
if (! isset($templateReference['templateDisplayName']) && isset($candidate['displayName'])) {
$templateReference['templateDisplayName'] = $candidate['displayName'];
}
if (! isset($templateReference['templateDisplayVersion']) && isset($candidate['displayVersion'])) {
$templateReference['templateDisplayVersion'] = $candidate['displayVersion'];
}
return [
'success' => true,
'template_id' => $resolvedId,
'template_reference' => $templateReference,
'reason' => null,
'warnings' => $warnings,
];
}
/**
* @param array<string, mixed> $graphOptions
* @return array{success:bool,template:?array,reason:?string}
*/
public function getTemplate(Tenant $tenant, string $templateId, array $graphOptions = []): array
{
$tenantKey = $this->tenantKey($tenant, $graphOptions);
if (isset($this->templateCache[$tenantKey][$templateId])) {
return $this->templateCache[$tenantKey][$templateId];
}
$context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']));
$path = sprintf('/deviceManagement/configurationPolicyTemplates/%s', urlencode($templateId));
$response = $this->graphClient->request('GET', $path, $context);
if ($response->failed()) {
return $this->templateCache[$tenantKey][$templateId] = [
'success' => false,
'template' => null,
'reason' => $response->meta['error_message'] ?? 'Template lookup failed.',
];
}
return $this->templateCache[$tenantKey][$templateId] = [
'success' => true,
'template' => $response->data,
'reason' => null,
];
}
/**
* @param array<string, mixed> $graphOptions
* @return array{success:bool,templates:array<int,array>,reason:?string}
*/
public function listTemplatesByFamily(Tenant $tenant, string $templateFamily, array $graphOptions = []): array
{
$tenantKey = $this->tenantKey($tenant, $graphOptions);
$cacheKey = strtolower($templateFamily);
if (isset($this->familyCache[$tenantKey][$cacheKey])) {
return $this->familyCache[$tenantKey][$cacheKey];
}
$escapedFamily = str_replace("'", "''", $templateFamily);
$context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [
'query' => [
'$filter' => "templateFamily eq '{$escapedFamily}'",
'$top' => 999,
],
]);
$response = $this->graphClient->request('GET', '/deviceManagement/configurationPolicyTemplates', $context);
if ($response->failed()) {
return $this->familyCache[$tenantKey][$cacheKey] = [
'success' => false,
'templates' => [],
'reason' => $response->meta['error_message'] ?? 'Template list failed.',
];
}
$value = $response->data['value'] ?? [];
$templates = is_array($value) ? array_values(array_filter($value, static fn ($item) => is_array($item))) : [];
return $this->familyCache[$tenantKey][$cacheKey] = [
'success' => true,
'templates' => $templates,
'reason' => null,
];
}
/**
* @param array<string, mixed> $graphOptions
* @return array{success:bool,definition_ids:array<int,string>,reason:?string}
*/
public function fetchTemplateSettingDefinitionIds(Tenant $tenant, string $templateId, array $graphOptions = []): array
{
$tenantKey = $this->tenantKey($tenant, $graphOptions);
if (isset($this->templateDefinitionCache[$tenantKey][$templateId])) {
return $this->templateDefinitionCache[$tenantKey][$templateId];
}
$context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [
'query' => [
'$expand' => 'settingDefinitions',
'$top' => 999,
],
]);
$path = sprintf('/deviceManagement/configurationPolicyTemplates/%s/settingTemplates', urlencode($templateId));
$response = $this->graphClient->request('GET', $path, $context);
if ($response->failed()) {
return $this->templateDefinitionCache[$tenantKey][$templateId] = [
'success' => false,
'definition_ids' => [],
'reason' => $response->meta['error_message'] ?? 'Template definitions lookup failed.',
];
}
$value = $response->data['value'] ?? [];
$templates = is_array($value) ? $value : [];
$definitionIds = [];
foreach ($templates as $settingTemplate) {
if (! is_array($settingTemplate)) {
continue;
}
$definitions = $settingTemplate['settingDefinitions'] ?? null;
if (! is_array($definitions)) {
continue;
}
foreach ($definitions as $definition) {
if (! is_array($definition)) {
continue;
}
$id = $definition['id'] ?? null;
if (is_string($id) && $id !== '') {
$definitionIds[] = $id;
}
}
}
$definitionIds = array_values(array_unique($definitionIds));
return $this->templateDefinitionCache[$tenantKey][$templateId] = [
'success' => true,
'definition_ids' => $definitionIds,
'reason' => null,
];
}
/**
* @param array<int, mixed> $settings
* @return array<int, string>
*/
public function extractSettingDefinitionIds(array $settings): array
{
$ids = [];
$walk = function (mixed $node) use (&$walk, &$ids): void {
if (! is_array($node)) {
return;
}
foreach ($node as $key => $value) {
if (is_string($key) && strtolower($key) === 'settingdefinitionid' && is_string($value) && $value !== '') {
$ids[] = $value;
}
$walk($value);
}
};
$walk($settings);
return array_values(array_unique($ids));
}
/**
* @param array<int, array> $templates
* @return array<int, array>
*/
private function chooseTemplateCandidate(array $templates, ?string $templateDisplayName, ?string $templateDisplayVersion): array
{
$candidates = $templates;
$active = array_values(array_filter($candidates, static function (array $template): bool {
$state = $template['lifecycleState'] ?? null;
return is_string($state) && strtolower($state) === 'active';
}));
if ($active !== []) {
$candidates = $active;
}
if ($templateDisplayVersion !== null) {
$byVersion = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayVersion): bool {
$version = $template['displayVersion'] ?? null;
return is_string($version) && $version === $templateDisplayVersion;
}));
if ($byVersion !== []) {
$candidates = $byVersion;
}
}
if ($templateDisplayName !== null) {
$byName = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayName): bool {
$name = $template['displayName'] ?? null;
return is_string($name) && $name === $templateDisplayName;
}));
if ($byName !== []) {
$candidates = $byName;
}
}
return $candidates;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys
*/
private function extractString(array $payload, array $keys): ?string
{
$normalized = array_map('strtolower', $keys);
foreach ($payload as $key => $value) {
if (! is_string($key) || ! in_array(strtolower($key), $normalized, true)) {
continue;
}
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return null;
}
/**
* @param array<string, mixed> $graphOptions
*/
private function tenantKey(Tenant $tenant, array $graphOptions): string
{
$tenantId = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
return (string) $tenantId;
}
}