feat: endpoint security restore execution (023)
This commit is contained in:
parent
d6a57c1828
commit
acc7148c43
@ -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