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
This commit is contained in:
parent
d6a57c1828
commit
d120ed7c92
@ -781,8 +781,17 @@ private function endpointFor(string $policyType): string
|
||||
return $contractResource;
|
||||
}
|
||||
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
foreach ($supported as $type) {
|
||||
$builtinEndpoint = $this->builtinEndpointFor($policyType);
|
||||
if ($builtinEndpoint !== null) {
|
||||
return $builtinEndpoint;
|
||||
}
|
||||
|
||||
$types = array_merge(
|
||||
config('tenantpilot.supported_policy_types', []),
|
||||
config('tenantpilot.foundation_types', []),
|
||||
);
|
||||
|
||||
foreach ($types as $type) {
|
||||
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
|
||||
return $type['endpoint'];
|
||||
}
|
||||
@ -791,6 +800,16 @@ private function endpointFor(string $policyType): string
|
||||
return 'deviceManagement/'.$policyType;
|
||||
}
|
||||
|
||||
private function builtinEndpointFor(string $policyType): ?string
|
||||
{
|
||||
return match ($policyType) {
|
||||
'settingsCatalogPolicy',
|
||||
'endpointSecurityPolicy',
|
||||
'securityBaselinePolicy' => 'deviceManagement/configurationPolicies',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function getAccessToken(array $context): string
|
||||
{
|
||||
$tenant = $context['tenant'] ?? $this->tenantId;
|
||||
|
||||
388
app/Services/Intune/ConfigurationPolicyTemplateResolver.php
Normal file
388
app/Services/Intune/ConfigurationPolicyTemplateResolver.php
Normal file
@ -0,0 +1,388 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ class RestoreRiskChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupResolver $groupResolver,
|
||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -40,6 +41,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem
|
||||
$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);
|
||||
@ -229,6 +231,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@ -669,7 +756,17 @@ private function resolveRestoreMode(?string $policyType): string
|
||||
{
|
||||
$meta = $this->resolveTypeMeta($policyType);
|
||||
|
||||
return (string) ($meta['restore'] ?? 'enabled');
|
||||
if ($meta === []) {
|
||||
return 'preview-only';
|
||||
}
|
||||
|
||||
$restore = $meta['restore'] ?? 'enabled';
|
||||
|
||||
if (! is_string($restore) || $restore === '') {
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
return $restore;
|
||||
}
|
||||
|
||||
private function resolveTypeLabel(?string $policyType): string
|
||||
|
||||
@ -27,6 +27,7 @@ public function __construct(
|
||||
private readonly VersionService $versionService,
|
||||
private readonly SnapshotValidator $snapshotValidator,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||
private readonly FoundationMappingService $foundationMappingService,
|
||||
) {}
|
||||
@ -430,12 +431,13 @@ public function execute(
|
||||
$createdPolicyMode = null;
|
||||
$settingsApplyEligible = false;
|
||||
|
||||
if ($item->policy_type === 'settingsCatalogPolicy') {
|
||||
if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) {
|
||||
$policyType = $item->policy_type;
|
||||
$settings = $this->extractSettingsCatalogSettings($originalPayload);
|
||||
$policyPayload = $this->stripSettingsFromPayload($payload);
|
||||
|
||||
$response = $this->graphClient->applyPolicy(
|
||||
$item->policy_type,
|
||||
$policyType,
|
||||
$item->policy_identifier,
|
||||
$policyPayload,
|
||||
$graphOptions + ['method' => $updateMethod]
|
||||
@ -443,8 +445,19 @@ public function execute(
|
||||
|
||||
$settingsApplyEligible = $response->successful();
|
||||
|
||||
if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) {
|
||||
if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) {
|
||||
if ($policyType === 'endpointSecurityPolicy') {
|
||||
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
|
||||
tenant: $tenant,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
$createOutcome = $this->createSettingsCatalogPolicy(
|
||||
policyType: $policyType,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
@ -488,6 +501,7 @@ public function execute(
|
||||
|
||||
if ($settingsApplyEligible && $settings !== []) {
|
||||
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
|
||||
policyType: $policyType,
|
||||
policyId: $item->policy_identifier,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
@ -496,7 +510,18 @@ public function execute(
|
||||
|
||||
if ($itemStatus === 'manual_required' && $settingsApply !== null
|
||||
&& $this->shouldAttemptSettingsCatalogCreate($settingsApply)) {
|
||||
if ($policyType === 'endpointSecurityPolicy') {
|
||||
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
|
||||
tenant: $tenant,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
$createOutcome = $this->createSettingsCatalogPolicy(
|
||||
policyType: $policyType,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
@ -539,14 +564,6 @@ public function execute(
|
||||
];
|
||||
}
|
||||
}
|
||||
} elseif ($settingsApplyEligible && $settings !== []) {
|
||||
$settingsApply = [
|
||||
'total' => count($settings),
|
||||
'applied' => 0,
|
||||
'failed' => count($settings),
|
||||
'manual_required' => 0,
|
||||
'issues' => [],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if ($item->policy_type === 'appProtectionPolicy') {
|
||||
@ -659,6 +676,8 @@ public function execute(
|
||||
'graph_error_code' => $response->meta['error_code'] ?? null,
|
||||
'graph_request_id' => $response->meta['request_id'] ?? null,
|
||||
'graph_client_request_id' => $response->meta['client_request_id'] ?? null,
|
||||
'graph_method' => $response->meta['method'] ?? null,
|
||||
'graph_path' => $response->meta['path'] ?? null,
|
||||
];
|
||||
$hardFailures++;
|
||||
|
||||
@ -914,6 +933,11 @@ private function resolveTypeMeta(string $policyType): array
|
||||
private function resolveRestoreMode(string $policyType): string
|
||||
{
|
||||
$meta = $this->resolveTypeMeta($policyType);
|
||||
|
||||
if ($meta === []) {
|
||||
return 'preview-only';
|
||||
}
|
||||
|
||||
$restore = $meta['restore'] ?? 'enabled';
|
||||
|
||||
if (! is_string($restore) || $restore === '') {
|
||||
@ -960,6 +984,10 @@ private function isNotFoundResponse(object $response): bool
|
||||
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
|
||||
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
|
||||
|
||||
if ($message !== '' && str_contains($message, 'resource not found for the segment')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) {
|
||||
return true;
|
||||
}
|
||||
@ -1508,15 +1536,16 @@ private function resolveSettingsCatalogSettingId(array $setting): ?string
|
||||
* @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array<int,array<string,mixed>>}, 1: string}
|
||||
*/
|
||||
private function applySettingsCatalogPolicySettings(
|
||||
string $policyType,
|
||||
string $policyId,
|
||||
array $settings,
|
||||
array $graphOptions,
|
||||
array $context,
|
||||
): array {
|
||||
$method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy');
|
||||
$path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId);
|
||||
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy'));
|
||||
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy');
|
||||
$method = $this->contracts->settingsWriteMethod($policyType);
|
||||
$path = $this->contracts->settingsWritePath($policyType, $policyId);
|
||||
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType));
|
||||
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType);
|
||||
|
||||
$buildIssues = function (string $reason) use ($settings): array {
|
||||
$issues = [];
|
||||
@ -1549,7 +1578,7 @@ private function applySettingsCatalogPolicySettings(
|
||||
];
|
||||
}
|
||||
|
||||
$sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings);
|
||||
$sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
|
||||
|
||||
if (! is_array($sanitized) || $sanitized === []) {
|
||||
return [
|
||||
@ -1683,14 +1712,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool
|
||||
* @return array{success:bool,policy_id:?string,response:?object,mode:string}
|
||||
*/
|
||||
private function createSettingsCatalogPolicy(
|
||||
string $policyType,
|
||||
array $originalPayload,
|
||||
array $settings,
|
||||
array $graphOptions,
|
||||
array $context,
|
||||
string $fallbackName,
|
||||
): array {
|
||||
$resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies';
|
||||
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings);
|
||||
$resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies';
|
||||
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
|
||||
|
||||
if ($sanitizedSettings === []) {
|
||||
return [
|
||||
@ -1747,6 +1777,79 @@ private function createSettingsCatalogPolicy(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $originalPayload
|
||||
* @param array<int, mixed> $settings
|
||||
* @param array<string, mixed> $graphOptions
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function prepareEndpointSecurityPolicyForCreate(
|
||||
Tenant $tenant,
|
||||
array $originalPayload,
|
||||
array $settings,
|
||||
array $graphOptions,
|
||||
array $context,
|
||||
): array {
|
||||
$templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']);
|
||||
|
||||
if (! is_array($templateReference)) {
|
||||
throw new \RuntimeException('Endpoint Security policy snapshot is missing templateReference and cannot be restored safely.');
|
||||
}
|
||||
|
||||
$templateOutcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions);
|
||||
|
||||
if (! ($templateOutcome['success'] ?? false)) {
|
||||
$reason = $templateOutcome['reason'] ?? 'Endpoint Security template is not available in the tenant.';
|
||||
|
||||
throw new \RuntimeException($reason);
|
||||
}
|
||||
|
||||
$resolvedTemplateId = $templateOutcome['template_id'] ?? null;
|
||||
$resolvedReference = $templateOutcome['template_reference'] ?? $templateReference;
|
||||
|
||||
if (! is_string($resolvedTemplateId) || $resolvedTemplateId === '') {
|
||||
throw new \RuntimeException('Endpoint Security template could not be resolved (missing template id).');
|
||||
}
|
||||
|
||||
if (is_array($resolvedReference) && $resolvedReference !== []) {
|
||||
$originalPayload['templateReference'] = $resolvedReference;
|
||||
}
|
||||
|
||||
if ($settings === []) {
|
||||
return $originalPayload;
|
||||
}
|
||||
|
||||
$definitions = $this->templateResolver->fetchTemplateSettingDefinitionIds($tenant, $resolvedTemplateId, $graphOptions);
|
||||
|
||||
if (! ($definitions['success'] ?? false)) {
|
||||
return $originalPayload;
|
||||
}
|
||||
|
||||
$templateDefinitionIds = $definitions['definition_ids'] ?? [];
|
||||
|
||||
if (! is_array($templateDefinitionIds) || $templateDefinitionIds === []) {
|
||||
return $originalPayload;
|
||||
}
|
||||
|
||||
$policyDefinitionIds = $this->templateResolver->extractSettingDefinitionIds($settings);
|
||||
$missing = array_values(array_diff($policyDefinitionIds, $templateDefinitionIds));
|
||||
|
||||
if ($missing === []) {
|
||||
return $originalPayload;
|
||||
}
|
||||
|
||||
$sample = implode(', ', array_slice($missing, 0, 5));
|
||||
$suffix = count($missing) > 5 ? sprintf(' (and %d more)', count($missing) - 5) : '';
|
||||
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Endpoint Security settings do not match the resolved template (%s). Missing setting definitions: %s%s',
|
||||
$resolvedTemplateId,
|
||||
$sample,
|
||||
$suffix,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
|
||||
*/
|
||||
|
||||
@ -143,6 +143,19 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_whitelist' => [
|
||||
'name',
|
||||
'description',
|
||||
],
|
||||
'update_map' => [
|
||||
'displayName' => 'name',
|
||||
],
|
||||
'update_strip_keys' => [
|
||||
'platforms',
|
||||
'technologies',
|
||||
'templateReference',
|
||||
'assignments',
|
||||
],
|
||||
'member_hydration_strategy' => 'subresource_settings',
|
||||
'subresources' => [
|
||||
'settings' => [
|
||||
@ -153,6 +166,13 @@
|
||||
'allowed_expand' => [],
|
||||
],
|
||||
],
|
||||
'settings_write' => [
|
||||
'path_template' => 'deviceManagement/configurationPolicies/{id}/settings',
|
||||
'method' => 'POST',
|
||||
'bulk' => true,
|
||||
'body_shape' => 'collection',
|
||||
'fallback_body_shape' => 'wrapped',
|
||||
],
|
||||
|
||||
// Assignments CRUD (standard Graph pattern)
|
||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||
@ -514,6 +534,11 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'isAssigned',
|
||||
'templateId',
|
||||
'isMigratingToConfigurationPolicy',
|
||||
],
|
||||
],
|
||||
'mobileApp' => [
|
||||
'resource' => 'deviceAppManagement/mobileApps',
|
||||
|
||||
@ -192,7 +192,7 @@
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/configurationPolicies',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
|
||||
@ -268,10 +268,16 @@
|
||||
@if (! empty($item['graph_error_code']))
|
||||
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
||||
@endif
|
||||
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']))
|
||||
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
|
||||
<div class="mt-1 space-y-0.5 text-[11px] text-amber-800">
|
||||
@if (! empty($item['graph_method']))
|
||||
<div>method: {{ $item['graph_method'] }}</div>
|
||||
@endif
|
||||
@if (! empty($item['graph_path']))
|
||||
<div>path: {{ $item['graph_path'] }}</div>
|
||||
@endif
|
||||
@if (! empty($item['graph_request_id']))
|
||||
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
||||
@endif
|
||||
|
||||
102
tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php
Normal file
102
tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class EndpointSecurityIntentRestoreGraphClient implements GraphClientInterface
|
||||
{
|
||||
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
|
||||
public array $applyPolicyCalls = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applyPolicyCalls[] = [
|
||||
'policyType' => $policyType,
|
||||
'policyId' => $policyId,
|
||||
'payload' => $payload,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
}
|
||||
|
||||
test('restore strips non-patchable fields from endpoint security intent updates', function () {
|
||||
$client = new EndpointSecurityIntentRestoreGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'intent-1',
|
||||
'policy_type' => 'endpointSecurityIntent',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'intent-1',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementIntent',
|
||||
'displayName' => 'SPO Account Protection',
|
||||
'description' => 'Demo',
|
||||
'isAssigned' => false,
|
||||
'templateId' => '0f2b5d70-d4e9-4156-8c16-1397eb6c54a5',
|
||||
'isMigratingToConfigurationPolicy' => false,
|
||||
],
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||
|
||||
$payload = $client->applyPolicyCalls[0]['payload'] ?? [];
|
||||
expect($payload)->toBeArray();
|
||||
expect($payload)->toHaveKey('displayName');
|
||||
expect($payload)->toHaveKey('description');
|
||||
expect($payload)->not->toHaveKey('id');
|
||||
expect($payload)->not->toHaveKey('isAssigned');
|
||||
expect($payload)->not->toHaveKey('templateId');
|
||||
expect($payload)->not->toHaveKey('isMigratingToConfigurationPolicy');
|
||||
});
|
||||
265
tests/Feature/EndpointSecurityPolicyRestore023Test.php
Normal file
265
tests/Feature/EndpointSecurityPolicyRestore023Test.php
Normal file
@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreRiskChecker;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class EndpointSecurityRestoreGraphClient implements GraphClientInterface
|
||||
{
|
||||
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
|
||||
public array $applyPolicyCalls = [];
|
||||
|
||||
/** @var array<int, array{method:string,path:string,options:array<string,mixed>}> */
|
||||
public array $requestCalls = [];
|
||||
|
||||
/**
|
||||
* @param array<string, GraphResponse> $requestMap
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly GraphResponse $applyPolicyResponse,
|
||||
private readonly array $requestMap = [],
|
||||
) {}
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applyPolicyCalls[] = [
|
||||
'policyType' => $policyType,
|
||||
'policyId' => $policyId,
|
||||
'payload' => $payload,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return $this->applyPolicyResponse;
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requestCalls[] = [
|
||||
'method' => strtoupper($method),
|
||||
'path' => $path,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
foreach ($this->requestMap as $needle => $response) {
|
||||
if (is_string($needle) && $needle !== '' && str_contains($path, $needle)) {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
}
|
||||
|
||||
test('restore executes endpoint security policy settings via settings endpoint', function () {
|
||||
$client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []));
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'esp-1',
|
||||
'policy_type' => 'endpointSecurityPolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'esp-1',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'name' => 'Endpoint Security Policy',
|
||||
'platforms' => ['windows10'],
|
||||
'technologies' => ['endpointSecurity'],
|
||||
'templateReference' => [
|
||||
'templateId' => 'template-1',
|
||||
'templateFamily' => 'endpointSecurityFirewall',
|
||||
'templateDisplayName' => 'Windows Firewall Rules',
|
||||
'templateDisplayVersion' => 'Version 1',
|
||||
],
|
||||
'settings' => [
|
||||
[
|
||||
'id' => 's1',
|
||||
'settingInstance' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
|
||||
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
|
||||
'simpleSettingValue' => [
|
||||
'value' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||
expect($client->applyPolicyCalls[0]['policyType'])->toBe('endpointSecurityPolicy');
|
||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings');
|
||||
|
||||
$settingsCalls = collect($client->requestCalls)
|
||||
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/settings'))
|
||||
->values();
|
||||
|
||||
expect($settingsCalls)->toHaveCount(1);
|
||||
expect($settingsCalls[0]['path'])->toContain('deviceManagement/configurationPolicies/esp-1/settings');
|
||||
|
||||
$body = $settingsCalls[0]['options']['json'] ?? null;
|
||||
expect($body)->toBeArray()->not->toBeEmpty();
|
||||
expect($body[0]['settingInstance']['settingDefinitionId'] ?? null)
|
||||
->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
|
||||
});
|
||||
|
||||
test('restore fails when endpoint security template is missing', function () {
|
||||
$applyNotFound = new GraphResponse(false, ['error' => ['message' => 'Not found']], 404, [], [], [
|
||||
'error_code' => 'NotFound',
|
||||
'error_message' => 'Not found',
|
||||
]);
|
||||
|
||||
$templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [
|
||||
'error_code' => 'NotFound',
|
||||
'error_message' => 'Template missing',
|
||||
]);
|
||||
|
||||
$client = new EndpointSecurityRestoreGraphClient($applyNotFound, [
|
||||
'configurationPolicyTemplates' => $templateNotFound,
|
||||
]);
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'esp-missing',
|
||||
'policy_type' => 'endpointSecurityPolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'esp-missing',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'name' => 'Endpoint Security Policy',
|
||||
'platforms' => ['windows10'],
|
||||
'technologies' => ['endpointSecurity'],
|
||||
'templateReference' => [
|
||||
'templateId' => 'missing-template',
|
||||
],
|
||||
'settings' => [
|
||||
[
|
||||
'id' => 's1',
|
||||
'settingInstance' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
|
||||
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
|
||||
'simpleSettingValue' => [
|
||||
'value' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('failed');
|
||||
|
||||
$createCalls = collect($client->requestCalls)
|
||||
->filter(fn (array $call) => $call['method'] === 'POST' && $call['path'] === 'deviceManagement/configurationPolicies')
|
||||
->values();
|
||||
|
||||
expect($createCalls)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('restore risk checks flag missing endpoint security templates as blocking', function () {
|
||||
$templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [
|
||||
'error_code' => 'NotFound',
|
||||
'error_message' => 'Template missing',
|
||||
]);
|
||||
|
||||
$client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []), [
|
||||
'configurationPolicyTemplates' => $templateNotFound,
|
||||
]);
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'esp-missing',
|
||||
'policy_type' => 'endpointSecurityPolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'esp-missing',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'templateReference' => [
|
||||
'templateId' => 'missing-template',
|
||||
'templateFamily' => 'endpointSecurityFirewall',
|
||||
],
|
||||
'settings' => [],
|
||||
],
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$checker = app(RestoreRiskChecker::class);
|
||||
$result = $checker->check(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
groupMapping: [],
|
||||
);
|
||||
|
||||
$results = collect($result['results'] ?? []);
|
||||
$templateCheck = $results->firstWhere('code', 'endpoint_security_templates');
|
||||
|
||||
expect($templateCheck)->not->toBeNull();
|
||||
expect($templateCheck['severity'] ?? null)->toBe('blocking');
|
||||
});
|
||||
@ -262,6 +262,6 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$byType = collect($preview)->keyBy('policy_type');
|
||||
|
||||
expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled');
|
||||
expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only');
|
||||
expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('enabled');
|
||||
expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only');
|
||||
});
|
||||
|
||||
102
tests/Feature/RestoreGraphErrorMetadataTest.php
Normal file
102
tests/Feature/RestoreGraphErrorMetadataTest.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class RestoreGraphErrorMetadataGraphClient implements GraphClientInterface
|
||||
{
|
||||
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
|
||||
public array $applyPolicyCalls = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applyPolicyCalls[] = [
|
||||
'policyType' => $policyType,
|
||||
'policyId' => $policyId,
|
||||
'payload' => $payload,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [
|
||||
'error_code' => 'BadRequest',
|
||||
'error_message' => "Resource not found for the segment 'endpointSecurityPolicy'.",
|
||||
'request_id' => 'req-1',
|
||||
'client_request_id' => 'client-1',
|
||||
'method' => 'PATCH',
|
||||
'path' => 'deviceManagement/endpointSecurityPolicy/esp-1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
}
|
||||
|
||||
test('restore results include graph path and method on Graph failures', function () {
|
||||
$client = new RestoreGraphErrorMetadataGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'esp-1',
|
||||
'policy_type' => 'endpointSecurityPolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'esp-1',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'name' => 'Endpoint Security Policy',
|
||||
'settings' => [],
|
||||
],
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
);
|
||||
|
||||
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||
expect($run->status)->toBe('failed');
|
||||
|
||||
$result = $run->results[0] ?? null;
|
||||
expect($result)->toBeArray();
|
||||
expect($result['graph_method'] ?? null)->toBe('PATCH');
|
||||
expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1');
|
||||
});
|
||||
117
tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php
Normal file
117
tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class RestoreUnknownTypeGraphClient implements GraphClientInterface
|
||||
{
|
||||
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
|
||||
public array $applyPolicyCalls = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applyPolicyCalls[] = [
|
||||
'policyType' => $policyType,
|
||||
'policyId' => $policyId,
|
||||
'payload' => $payload,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [
|
||||
'error_code' => 'BadRequest',
|
||||
'error_message' => 'Bad request',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->originalSupportedTypes = config('tenantpilot.supported_policy_types');
|
||||
$this->originalSecurityBaselineContract = config('graph_contracts.types.securityBaselinePolicy');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
config()->set('tenantpilot.supported_policy_types', $this->originalSupportedTypes);
|
||||
|
||||
if (is_array($this->originalSecurityBaselineContract)) {
|
||||
config()->set('graph_contracts.types.securityBaselinePolicy', $this->originalSecurityBaselineContract);
|
||||
}
|
||||
});
|
||||
|
||||
test('restore skips security baseline policies when type metadata is missing', function () {
|
||||
$client = new RestoreUnknownTypeGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$supported = array_values(array_filter(
|
||||
config('tenantpilot.supported_policy_types', []),
|
||||
static fn (array $type): bool => ($type['type'] ?? null) !== 'securityBaselinePolicy'
|
||||
));
|
||||
|
||||
config()->set('tenantpilot.supported_policy_types', $supported);
|
||||
config()->set('graph_contracts.types.securityBaselinePolicy', []);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'baseline-1',
|
||||
'policy_type' => 'securityBaselinePolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'baseline-1',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'name' => 'Security Baseline Policy',
|
||||
],
|
||||
'assignments' => null,
|
||||
]);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
);
|
||||
|
||||
expect($client->applyPolicyCalls)->toHaveCount(0);
|
||||
|
||||
$result = $run->results[0] ?? null;
|
||||
expect($result)->toBeArray();
|
||||
expect($result['status'] ?? null)->toBe('skipped');
|
||||
expect($result['restore_mode'] ?? null)->toBe('preview-only');
|
||||
});
|
||||
@ -61,3 +61,40 @@
|
||||
return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1');
|
||||
});
|
||||
});
|
||||
|
||||
it('uses built-in endpoint mapping for endpoint security policies when config is missing', function () {
|
||||
config()->set('graph_contracts.types.endpointSecurityPolicy', []);
|
||||
config()->set('tenantpilot.foundation_types', []);
|
||||
|
||||
Http::fake([
|
||||
'https://login.microsoftonline.com/*' => Http::response([
|
||||
'access_token' => 'fake-token',
|
||||
'expires_in' => 3600,
|
||||
], 200),
|
||||
'https://graph.microsoft.com/*' => Http::response(['id' => 'E_1'], 200),
|
||||
]);
|
||||
|
||||
$client = new MicrosoftGraphClient(
|
||||
logger: app(GraphLogger::class),
|
||||
contracts: app(GraphContractRegistry::class),
|
||||
);
|
||||
|
||||
$client->applyPolicy(
|
||||
policyType: 'endpointSecurityPolicy',
|
||||
policyId: 'E_1',
|
||||
payload: ['name' => 'Test'],
|
||||
options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'],
|
||||
);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
if (! str_contains($request->url(), 'graph.microsoft.com')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! str_contains($request->url(), '/beta/deviceManagement/configurationPolicies/E_1')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! str_contains($request->url(), '/beta/deviceManagement/endpointSecurityPolicy/E_1');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user