Compare commits
10 Commits
dev
...
feat/023-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823b7589f6 | ||
|
|
c852b80701 | ||
|
|
25f78b15ad | ||
|
|
5595bb52b6 | ||
|
|
f23cf8a83f | ||
|
|
1d158ca9bf | ||
|
|
d54a245a58 | ||
|
|
088c0fc9e9 | ||
|
|
b49c5508dc | ||
|
|
95174c0b1b |
@ -781,8 +781,17 @@ private function endpointFor(string $policyType): string
|
|||||||
return $contractResource;
|
return $contractResource;
|
||||||
}
|
}
|
||||||
|
|
||||||
$supported = config('tenantpilot.supported_policy_types', []);
|
$builtinEndpoint = $this->builtinEndpointFor($policyType);
|
||||||
foreach ($supported as $type) {
|
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'])) {
|
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
|
||||||
return $type['endpoint'];
|
return $type['endpoint'];
|
||||||
}
|
}
|
||||||
@ -791,6 +800,16 @@ private function endpointFor(string $policyType): string
|
|||||||
return 'deviceManagement/'.$policyType;
|
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
|
private function getAccessToken(array $context): string
|
||||||
{
|
{
|
||||||
$tenant = $context['tenant'] ?? $this->tenantId;
|
$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(
|
public function __construct(
|
||||||
private readonly GroupResolver $groupResolver,
|
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->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
|
||||||
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
|
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
|
||||||
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
||||||
|
$results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems);
|
||||||
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
||||||
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
||||||
$results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null);
|
$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.
|
* Detect snapshots that were captured as metadata-only.
|
||||||
*
|
*
|
||||||
@ -669,7 +756,17 @@ private function resolveRestoreMode(?string $policyType): string
|
|||||||
{
|
{
|
||||||
$meta = $this->resolveTypeMeta($policyType);
|
$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
|
private function resolveTypeLabel(?string $policyType): string
|
||||||
|
|||||||
@ -27,6 +27,7 @@ public function __construct(
|
|||||||
private readonly VersionService $versionService,
|
private readonly VersionService $versionService,
|
||||||
private readonly SnapshotValidator $snapshotValidator,
|
private readonly SnapshotValidator $snapshotValidator,
|
||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
|
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||||
private readonly FoundationMappingService $foundationMappingService,
|
private readonly FoundationMappingService $foundationMappingService,
|
||||||
) {}
|
) {}
|
||||||
@ -430,12 +431,13 @@ public function execute(
|
|||||||
$createdPolicyMode = null;
|
$createdPolicyMode = null;
|
||||||
$settingsApplyEligible = false;
|
$settingsApplyEligible = false;
|
||||||
|
|
||||||
if ($item->policy_type === 'settingsCatalogPolicy') {
|
if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) {
|
||||||
|
$policyType = $item->policy_type;
|
||||||
$settings = $this->extractSettingsCatalogSettings($originalPayload);
|
$settings = $this->extractSettingsCatalogSettings($originalPayload);
|
||||||
$policyPayload = $this->stripSettingsFromPayload($payload);
|
$policyPayload = $this->stripSettingsFromPayload($payload);
|
||||||
|
|
||||||
$response = $this->graphClient->applyPolicy(
|
$response = $this->graphClient->applyPolicy(
|
||||||
$item->policy_type,
|
$policyType,
|
||||||
$item->policy_identifier,
|
$item->policy_identifier,
|
||||||
$policyPayload,
|
$policyPayload,
|
||||||
$graphOptions + ['method' => $updateMethod]
|
$graphOptions + ['method' => $updateMethod]
|
||||||
@ -443,8 +445,19 @@ public function execute(
|
|||||||
|
|
||||||
$settingsApplyEligible = $response->successful();
|
$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(
|
$createOutcome = $this->createSettingsCatalogPolicy(
|
||||||
|
policyType: $policyType,
|
||||||
originalPayload: $originalPayload,
|
originalPayload: $originalPayload,
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
graphOptions: $graphOptions,
|
graphOptions: $graphOptions,
|
||||||
@ -488,6 +501,7 @@ public function execute(
|
|||||||
|
|
||||||
if ($settingsApplyEligible && $settings !== []) {
|
if ($settingsApplyEligible && $settings !== []) {
|
||||||
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
|
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
|
||||||
|
policyType: $policyType,
|
||||||
policyId: $item->policy_identifier,
|
policyId: $item->policy_identifier,
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
graphOptions: $graphOptions,
|
graphOptions: $graphOptions,
|
||||||
@ -496,7 +510,18 @@ public function execute(
|
|||||||
|
|
||||||
if ($itemStatus === 'manual_required' && $settingsApply !== null
|
if ($itemStatus === 'manual_required' && $settingsApply !== null
|
||||||
&& $this->shouldAttemptSettingsCatalogCreate($settingsApply)) {
|
&& $this->shouldAttemptSettingsCatalogCreate($settingsApply)) {
|
||||||
|
if ($policyType === 'endpointSecurityPolicy') {
|
||||||
|
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
|
||||||
|
tenant: $tenant,
|
||||||
|
originalPayload: $originalPayload,
|
||||||
|
settings: $settings,
|
||||||
|
graphOptions: $graphOptions,
|
||||||
|
context: $context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$createOutcome = $this->createSettingsCatalogPolicy(
|
$createOutcome = $this->createSettingsCatalogPolicy(
|
||||||
|
policyType: $policyType,
|
||||||
originalPayload: $originalPayload,
|
originalPayload: $originalPayload,
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
graphOptions: $graphOptions,
|
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 {
|
} else {
|
||||||
if ($item->policy_type === 'appProtectionPolicy') {
|
if ($item->policy_type === 'appProtectionPolicy') {
|
||||||
@ -659,6 +676,8 @@ public function execute(
|
|||||||
'graph_error_code' => $response->meta['error_code'] ?? null,
|
'graph_error_code' => $response->meta['error_code'] ?? null,
|
||||||
'graph_request_id' => $response->meta['request_id'] ?? null,
|
'graph_request_id' => $response->meta['request_id'] ?? null,
|
||||||
'graph_client_request_id' => $response->meta['client_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++;
|
$hardFailures++;
|
||||||
|
|
||||||
@ -914,6 +933,11 @@ private function resolveTypeMeta(string $policyType): array
|
|||||||
private function resolveRestoreMode(string $policyType): string
|
private function resolveRestoreMode(string $policyType): string
|
||||||
{
|
{
|
||||||
$meta = $this->resolveTypeMeta($policyType);
|
$meta = $this->resolveTypeMeta($policyType);
|
||||||
|
|
||||||
|
if ($meta === []) {
|
||||||
|
return 'preview-only';
|
||||||
|
}
|
||||||
|
|
||||||
$restore = $meta['restore'] ?? 'enabled';
|
$restore = $meta['restore'] ?? 'enabled';
|
||||||
|
|
||||||
if (! is_string($restore) || $restore === '') {
|
if (! is_string($restore) || $restore === '') {
|
||||||
@ -960,6 +984,10 @@ private function isNotFoundResponse(object $response): bool
|
|||||||
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
|
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
|
||||||
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
|
$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'))) {
|
if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) {
|
||||||
return true;
|
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}
|
* @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array<int,array<string,mixed>>}, 1: string}
|
||||||
*/
|
*/
|
||||||
private function applySettingsCatalogPolicySettings(
|
private function applySettingsCatalogPolicySettings(
|
||||||
|
string $policyType,
|
||||||
string $policyId,
|
string $policyId,
|
||||||
array $settings,
|
array $settings,
|
||||||
array $graphOptions,
|
array $graphOptions,
|
||||||
array $context,
|
array $context,
|
||||||
): array {
|
): array {
|
||||||
$method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy');
|
$method = $this->contracts->settingsWriteMethod($policyType);
|
||||||
$path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId);
|
$path = $this->contracts->settingsWritePath($policyType, $policyId);
|
||||||
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy'));
|
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType));
|
||||||
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy');
|
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType);
|
||||||
|
|
||||||
$buildIssues = function (string $reason) use ($settings): array {
|
$buildIssues = function (string $reason) use ($settings): array {
|
||||||
$issues = [];
|
$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 === []) {
|
if (! is_array($sanitized) || $sanitized === []) {
|
||||||
return [
|
return [
|
||||||
@ -1683,14 +1712,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool
|
|||||||
* @return array{success:bool,policy_id:?string,response:?object,mode:string}
|
* @return array{success:bool,policy_id:?string,response:?object,mode:string}
|
||||||
*/
|
*/
|
||||||
private function createSettingsCatalogPolicy(
|
private function createSettingsCatalogPolicy(
|
||||||
|
string $policyType,
|
||||||
array $originalPayload,
|
array $originalPayload,
|
||||||
array $settings,
|
array $settings,
|
||||||
array $graphOptions,
|
array $graphOptions,
|
||||||
array $context,
|
array $context,
|
||||||
string $fallbackName,
|
string $fallbackName,
|
||||||
): array {
|
): array {
|
||||||
$resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies';
|
$resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies';
|
||||||
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings);
|
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
|
||||||
|
|
||||||
if ($sanitizedSettings === []) {
|
if ($sanitizedSettings === []) {
|
||||||
return [
|
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}
|
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -143,6 +143,19 @@
|
|||||||
'update_method' => 'PATCH',
|
'update_method' => 'PATCH',
|
||||||
'id_field' => 'id',
|
'id_field' => 'id',
|
||||||
'hydration' => 'properties',
|
'hydration' => 'properties',
|
||||||
|
'update_whitelist' => [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
],
|
||||||
|
'update_map' => [
|
||||||
|
'displayName' => 'name',
|
||||||
|
],
|
||||||
|
'update_strip_keys' => [
|
||||||
|
'platforms',
|
||||||
|
'technologies',
|
||||||
|
'templateReference',
|
||||||
|
'assignments',
|
||||||
|
],
|
||||||
'member_hydration_strategy' => 'subresource_settings',
|
'member_hydration_strategy' => 'subresource_settings',
|
||||||
'subresources' => [
|
'subresources' => [
|
||||||
'settings' => [
|
'settings' => [
|
||||||
@ -153,6 +166,13 @@
|
|||||||
'allowed_expand' => [],
|
'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 CRUD (standard Graph pattern)
|
||||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||||
@ -514,6 +534,11 @@
|
|||||||
'update_method' => 'PATCH',
|
'update_method' => 'PATCH',
|
||||||
'id_field' => 'id',
|
'id_field' => 'id',
|
||||||
'hydration' => 'properties',
|
'hydration' => 'properties',
|
||||||
|
'update_strip_keys' => [
|
||||||
|
'isAssigned',
|
||||||
|
'templateId',
|
||||||
|
'isMigratingToConfigurationPolicy',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'mobileApp' => [
|
'mobileApp' => [
|
||||||
'resource' => 'deviceAppManagement/mobileApps',
|
'resource' => 'deviceAppManagement/mobileApps',
|
||||||
|
|||||||
@ -192,7 +192,7 @@
|
|||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
'endpoint' => 'deviceManagement/configurationPolicies',
|
'endpoint' => 'deviceManagement/configurationPolicies',
|
||||||
'backup' => 'full',
|
'backup' => 'full',
|
||||||
'restore' => 'preview-only',
|
'restore' => 'enabled',
|
||||||
'risk' => 'high',
|
'risk' => 'high',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@ -268,10 +268,16 @@
|
|||||||
@if (! empty($item['graph_error_code']))
|
@if (! empty($item['graph_error_code']))
|
||||||
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
||||||
@endif
|
@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">
|
<details class="mt-1">
|
||||||
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
|
<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">
|
<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']))
|
@if (! empty($item['graph_request_id']))
|
||||||
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
# Requirements Checklist (023)
|
||||||
|
|
||||||
|
**Created**: 2026-01-03
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
- [ ] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`.
|
||||||
|
- [ ] Restore preview validates template existence and reports missing/ambiguous templates.
|
||||||
|
- [ ] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message.
|
||||||
|
- [ ] Settings instances are validated against resolved template definitions before execution.
|
||||||
|
- [ ] Template mapping strategy is defined for cross-tenant differences (if required) and is tested.
|
||||||
|
- [ ] Restore create + update paths for Endpoint Security policies are covered by automated tests.
|
||||||
|
- [ ] Assignments mapping/application for Endpoint Security policies are covered by automated tests.
|
||||||
|
- [ ] Audit log entries exist for restore execution attempts (success and failure).
|
||||||
|
|
||||||
32
specs/023-endpoint-security-restore/plan.md
Normal file
32
specs/023-endpoint-security-restore/plan.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Plan: Endpoint Security Policy Restore (023)
|
||||||
|
|
||||||
|
**Branch**: `feat/023-endpoint-security-restore`
|
||||||
|
**Date**: 2026-01-03
|
||||||
|
**Input**: [spec.md](./spec.md)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Enable full restore execution for Endpoint Security Policies (`endpointSecurityPolicy`) instead of preview-only, with defensive validation around templates and settings payloads.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
1. Enable restore execution in `config/tenantpilot.php` by switching `endpointSecurityPolicy.restore` from `preview-only` to `enabled`.
|
||||||
|
2. Add template existence validation during restore preview:
|
||||||
|
- Resolve the snapshot’s `templateReference` (family/id/display name where available).
|
||||||
|
- Confirm the referenced template is resolvable in the target tenant before execution.
|
||||||
|
- Surface warnings in preview and fail execution with a clear error when missing.
|
||||||
|
3. Add settings instance validation prior to execution:
|
||||||
|
- Resolve template definitions for the target tenant.
|
||||||
|
- Validate that settings instances are structurally compatible with the resolved template.
|
||||||
|
- Treat validation failures as preview warnings, and block execution when the payload cannot be made safe.
|
||||||
|
4. Ensure restore uses the existing generic configuration policy create/update flow:
|
||||||
|
- Create when no match exists; update when matched (per existing restore matching rules).
|
||||||
|
- Apply assignments using existing mapping logic.
|
||||||
|
5. Add targeted tests covering:
|
||||||
|
- Create + update restore execution for `endpointSecurityPolicy`.
|
||||||
|
- Preview warnings and execution failure when template is missing.
|
||||||
|
- Settings validation failure paths.
|
||||||
|
- Assignment application expectations.
|
||||||
|
|
||||||
|
## Decisions / Notes
|
||||||
|
- Assume template identifiers may differ across tenants; prefer mapping by `templateFamily` with display-name fallback when required.
|
||||||
|
- Safety-first: if template resolution is ambiguous, treat as missing and block execution.
|
||||||
|
|
||||||
93
specs/023-endpoint-security-restore/spec.md
Normal file
93
specs/023-endpoint-security-restore/spec.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Feature Specification: Enable Endpoint Security Policy Restore (023)
|
||||||
|
|
||||||
|
**Feature Branch**: `feat/023-endpoint-security-restore`
|
||||||
|
**Created**: 2026-01-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Priority**: P1 (Quick Win)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Endpoint Security Policies are already in the `tenantpilot.php` config as `endpointSecurityPolicy` with `restore => 'preview-only'`. Based on Microsoft's recommendation to use the unified `deviceManagement/configurationPolicies` endpoint (over the deprecated `intents` API for new creations), we should enable full restore for this type.
|
||||||
|
|
||||||
|
This is a **configuration-only change** with additional validation/testing, not a new policy type implementation.
|
||||||
|
|
||||||
|
## User Scenarios & Testing
|
||||||
|
|
||||||
|
### User Story 1 — Restore Endpoint Security Policies (Priority: P1)
|
||||||
|
As an admin, I want to restore Endpoint Security Policies (Firewall, Defender, ASR, BitLocker, etc.) from backup, so I can recover from configuration errors or replicate security baselines across tenants.
|
||||||
|
|
||||||
|
**Why this priority**: These are high-impact security policies; restore is a core safety feature.
|
||||||
|
|
||||||
|
**Independent Test**: Restore an Endpoint Security Policy snapshot; verify settings and assignments are applied correctly.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**
|
||||||
|
1. Given an Endpoint Security Policy snapshot (e.g., Firewall), when I restore to a tenant without that policy, then a new policy is created with matching settings.
|
||||||
|
2. Given an Endpoint Security Policy snapshot, when I restore to a tenant with an existing policy (name match), then the policy is updated.
|
||||||
|
3. Given such a policy has assignments, when I restore, then assignments are mapped and applied.
|
||||||
|
|
||||||
|
### User Story 2 — Template Validation (Priority: P1)
|
||||||
|
As an admin, I want clear warnings if an Endpoint Security template is not available in the target tenant, so I understand restore limitations.
|
||||||
|
|
||||||
|
**Why this priority**: Templates are version-dependent; missing templates must be surfaced.
|
||||||
|
|
||||||
|
**Independent Test**: Attempt to restore a policy referencing a template not present in target; verify preview shows a warning.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**
|
||||||
|
1. Given a policy snapshot references a template ID, when I restore to a tenant without that template, then preview warns about missing template.
|
||||||
|
2. Given such a scenario, when I execute restore, then the operation fails gracefully with a clear error message.
|
||||||
|
|
||||||
|
### User Story 3 — Settings Instance Consistency (Priority: P2)
|
||||||
|
As an admin, I want settings instances to be validated against template definitions, so restored policies are valid.
|
||||||
|
|
||||||
|
**Why this priority**: Settings must match template structure; invalid settings break policies.
|
||||||
|
|
||||||
|
**Independent Test**: Restore a policy with settings; verify Graph API accepts the settings payload.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**
|
||||||
|
1. Given a policy snapshot with settings, when I restore, then settings are validated before submission to Graph API.
|
||||||
|
2. Given settings validation detects structural issues, when running preview, then warnings indicate which settings may be problematic.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
- **FR-001**: Change `restore` value from `'preview-only'` to `'enabled'` for `endpointSecurityPolicy` in config
|
||||||
|
- **FR-002**: Add template existence validation in restore preview
|
||||||
|
- **FR-003**: Ensure settings instance validation against template structure
|
||||||
|
- **FR-004**: Update Graph contract for `endpointSecurityPolicy` if needed (may already exist)
|
||||||
|
- **FR-005**: Add template ID mapping (if templates have different IDs across tenants)
|
||||||
|
- **FR-006**: Add comprehensive restore tests for common Endpoint Security policy types:
|
||||||
|
- Antivirus (Defender)
|
||||||
|
- Firewall
|
||||||
|
- Disk Encryption (BitLocker)
|
||||||
|
- Attack Surface Reduction (ASR)
|
||||||
|
- Account Protection
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
- **NFR-001**: Restore preview must complete within 5 seconds for typical policy
|
||||||
|
- **NFR-002**: Template validation must not significantly slow down preview
|
||||||
|
- **NFR-003**: All common Endpoint Security policy types must be covered by tests
|
||||||
|
|
||||||
|
### Graph API Details
|
||||||
|
- **Endpoint**: `https://graph.microsoft.com/beta/deviceManagement/configurationPolicies`
|
||||||
|
- **Filter** (if needed): `templateReference/templateFamily eq 'endpointSecurity...'`
|
||||||
|
- **Template Families**:
|
||||||
|
- `endpointSecurityAntivirus`
|
||||||
|
- `endpointSecurityFirewall`
|
||||||
|
- `endpointSecurityDiskEncryption`
|
||||||
|
- `endpointSecurityAttackSurfaceReduction`
|
||||||
|
- `endpointSecurityAccountProtection`
|
||||||
|
- etc.
|
||||||
|
- **Required Permissions**: `DeviceManagementConfiguration.ReadWrite.All`
|
||||||
|
|
||||||
|
### Known Considerations
|
||||||
|
- **Template Versioning**: Templates can evolve; settings structure may change
|
||||||
|
- **Platform Differences**: Some templates are Windows 10 only, others support Windows 11+
|
||||||
|
- **Settings Validation**: Graph API will reject invalid settings; catch this in preview
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- **SC-001**: Config change applied: `endpointSecurityPolicy` has `restore => 'enabled'`
|
||||||
|
- **SC-002**: Restore preview shows accurate change summary for Endpoint Security policies
|
||||||
|
- **SC-003**: Restore executes successfully for common policy types (Firewall, Antivirus, BitLocker)
|
||||||
|
- **SC-004**: Template existence validation catches missing templates before execution
|
||||||
|
- **SC-005**: Settings instance validation prevents invalid payloads
|
||||||
|
- **SC-006**: No regressions in sync or backup for this policy type
|
||||||
|
- **SC-007**: Feature tests cover restore success and failure scenarios
|
||||||
32
specs/023-endpoint-security-restore/tasks.md
Normal file
32
specs/023-endpoint-security-restore/tasks.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Tasks: Endpoint Security Policy Restore (023)
|
||||||
|
|
||||||
|
**Branch**: `feat/023-endpoint-security-restore`
|
||||||
|
**Date**: 2026-01-03
|
||||||
|
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
- [x] T001 Create spec/plan/tasks and checklist.
|
||||||
|
|
||||||
|
## Phase 2: Inventory & Design
|
||||||
|
- [ ] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services).
|
||||||
|
- [ ] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls.
|
||||||
|
- [ ] T004 Define settings instance validation rules (warning vs block) for restore preview/execution.
|
||||||
|
|
||||||
|
## Phase 3: Tests (TDD)
|
||||||
|
- [ ] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`.
|
||||||
|
- [ ] T006 Add feature tests for preview warnings when template is missing.
|
||||||
|
- [ ] T007 Add feature tests asserting restore execution fails gracefully when template is missing.
|
||||||
|
- [ ] T008 Add tests for settings validation failure paths (invalid/unknown settings instances).
|
||||||
|
- [ ] T009 Add feature tests asserting assignments are applied for endpoint security policies.
|
||||||
|
|
||||||
|
## Phase 4: Implementation
|
||||||
|
- [ ] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`.
|
||||||
|
- [ ] T011 Implement template existence validation in restore preview and execution gating.
|
||||||
|
- [ ] T012 Implement settings instance validation against resolved template definitions.
|
||||||
|
- [ ] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference.
|
||||||
|
- [ ] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic.
|
||||||
|
|
||||||
|
## Phase 5: Verification
|
||||||
|
- [ ] T015 Run targeted tests.
|
||||||
|
- [ ] T016 Run Pint (`./vendor/bin/pint --dirty`).
|
||||||
|
|
||||||
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');
|
$byType = collect($preview)->keyBy('policy_type');
|
||||||
|
|
||||||
expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled');
|
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');
|
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');
|
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