TenantAtlas/app/Services/Intune/PolicySyncService.php
ahmido 817ad208da feat/027-enrollment-config-subtypes (#31)
expose enrollment config subtypes as their own policy types (limit/platform restrictions/notifications) with preview-only restore risk and proper Graph contracts
classify enrollment configs by their @odata.type + deviceEnrollmentConfigurationType so sync only keeps ESP in windowsEnrollmentStatusPage and the rest stay in their own types, including new restore-normalizer UI blocks + warnings
hydrate enrollment notifications: snapshot fetch now downloads each notification template + localized messages, normalized view surfaces template names/subjects/messages, and restore previews keep preview-only behavior
tenant UI tweaks: Tenant list and detail actions moved into an action group; “Open in Entra” re-added in index, and detail now has “Deactivate” + tests covering the new menu layout and actions
tests added/updated for sync, snapshots, restores, normalized settings, tenant UI, plus Pint/test suite run

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #31
2026-01-04 13:25:15 +00:00

500 lines
17 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use Illuminate\Support\Arr;
use RuntimeException;
use Throwable;
class PolicySyncService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphLogger $graphLogger,
) {}
/**
* Sync supported policies for a tenant from Microsoft Graph.
*
* @return array<int> IDs of policies synced or created
*/
public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array
{
$result = $this->syncPoliciesWithReport($tenant, $supportedTypes);
return $result['synced'];
}
/**
* Sync supported policies for a tenant from Microsoft Graph.
*
* @param array<int, array{type: string, platform?: string|null, filter?: string|null}>|null $supportedTypes
* @return array{synced: array<int>, failures: array<int, array{policy_type: string, status: int|null, errors: array, meta: array}>}
*/
public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array
{
if (! $tenant->isActive()) {
throw new \RuntimeException('Tenant is archived or inactive.');
}
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
$synced = [];
$failures = [];
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
foreach ($types as $typeConfig) {
$policyType = $typeConfig['type'];
$platform = $typeConfig['platform'] ?? null;
$filter = $typeConfig['filter'] ?? null;
$this->graphLogger->logRequest('list_policies', [
'tenant' => $tenantIdentifier,
'policy_type' => $policyType,
'platform' => $platform,
'filter' => $filter,
]);
try {
$response = $this->graphClient->listPolicies($policyType, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $platform,
'filter' => $filter,
]);
} catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable($throwable, [
'policy_type' => $policyType,
'tenant_id' => $tenant->id,
'tenant_identifier' => $tenantIdentifier,
]);
}
$this->graphLogger->logResponse('list_policies', $response, [
'policy_type' => $policyType,
'tenant_id' => $tenant->id,
'tenant' => $tenantIdentifier,
]);
if ($response->failed()) {
$failures[] = [
'policy_type' => $policyType,
'status' => $response->status,
'errors' => $response->errors,
'meta' => $response->meta,
];
continue;
}
foreach ($response->data as $policyData) {
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
if ($externalId === null) {
continue;
}
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
if ($canonicalPolicyType !== $policyType) {
continue;
}
if ($policyType === 'appProtectionPolicy') {
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
if (is_string($odataType) && strtolower($odataType) === '#microsoft.graph.targetedmanagedappconfiguration') {
Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
continue;
}
}
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
tenantId: $tenant->id,
externalId: $externalId,
policyType: $policyType,
);
$this->reclassifyConfigurationPoliciesIfNeeded(
tenantId: $tenant->id,
externalId: $externalId,
policyType: $policyType,
);
$policy = Policy::updateOrCreate(
[
'tenant_id' => $tenant->id,
'external_id' => $externalId,
'policy_type' => $policyType,
],
[
'display_name' => $displayName,
'platform' => $policyPlatform,
'last_synced_at' => now(),
'ignored_at' => null,
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
]
);
$synced[] = $policy->id;
}
}
return [
'synced' => $synced,
'failures' => $failures,
];
}
private function resolveCanonicalPolicyType(string $policyType, array $policyData): string
{
if (in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
return $this->resolveConfigurationPolicyType($policyData);
}
$enrollmentConfigurationTypes = [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
];
if (! in_array($policyType, $enrollmentConfigurationTypes, true)) {
return $policyType;
}
if ($this->isEnrollmentStatusPageItem($policyData)) {
return 'windowsEnrollmentStatusPage';
}
if ($this->isEnrollmentNotificationItem($policyData)) {
return 'deviceEnrollmentNotificationConfiguration';
}
if ($this->isEnrollmentLimitItem($policyData)) {
return 'deviceEnrollmentLimitConfiguration';
}
if ($this->isEnrollmentPlatformRestrictionsItem($policyData)) {
return 'deviceEnrollmentPlatformRestrictionsConfiguration';
}
return 'enrollmentRestriction';
}
private function resolveConfigurationPolicyType(array $policyData): string
{
if ($this->isSecurityBaselineConfigurationPolicy($policyData)) {
return 'securityBaselinePolicy';
}
if ($this->isEndpointSecurityConfigurationPolicy($policyData)) {
return 'endpointSecurityPolicy';
}
return 'settingsCatalogPolicy';
}
private function isEndpointSecurityConfigurationPolicy(array $policyData): bool
{
$technologies = $policyData['technologies'] ?? null;
if (is_string($technologies)) {
if (strcasecmp(trim($technologies), 'endpointSecurity') === 0) {
return true;
}
}
if (is_array($technologies)) {
foreach ($technologies as $technology) {
if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) {
return true;
}
}
}
$templateReference = $policyData['templateReference'] ?? null;
if (! is_array($templateReference)) {
return false;
}
foreach ($templateReference as $value) {
if (is_string($value) && stripos($value, 'endpoint') !== false) {
return true;
}
}
return false;
}
private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
{
$templateReference = $policyData['templateReference'] ?? null;
if (! is_array($templateReference)) {
return false;
}
$templateFamily = $templateReference['templateFamily'] ?? null;
if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) {
return true;
}
foreach ($templateReference as $value) {
if (is_string($value) && stripos($value, 'baseline') !== false) {
return true;
}
}
return false;
}
private function isEnrollmentStatusPageItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0)
|| (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration');
}
private function isEnrollmentLimitItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentLimitConfiguration') === 0)
|| (is_string($configurationType) && strcasecmp($configurationType, 'deviceEnrollmentLimitConfiguration') === 0);
}
private function isEnrollmentPlatformRestrictionsItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
if (is_string($odataType) && $odataType !== '') {
$odataTypeKey = strtolower($odataType);
if (in_array($odataTypeKey, [
'#microsoft.graph.deviceenrollmentplatformrestrictionconfiguration',
'#microsoft.graph.deviceenrollmentplatformrestrictionsconfiguration',
], true)) {
return true;
}
}
if (is_string($configurationType) && $configurationType !== '') {
$configurationTypeKey = strtolower($configurationType);
if (in_array($configurationTypeKey, [
'deviceenrollmentplatformrestrictionconfiguration',
'deviceenrollmentplatformrestrictionsconfiguration',
], true)) {
return true;
}
}
return false;
}
private function isEnrollmentNotificationItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
if (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentNotificationConfiguration') === 0) {
return true;
}
if (! is_string($configurationType) || $configurationType === '') {
return false;
}
return in_array(strtolower($configurationType), [
'enrollmentnotificationsconfiguration',
'deviceenrollmentnotificationconfiguration',
], true);
}
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$enrollmentTypes = [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
];
if (! in_array($policyType, $enrollmentTypes, true)) {
return;
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $enrollmentTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $enrollmentTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->first();
if (! $existingWrong) {
return;
}
$existingWrong->forceFill([
'policy_type' => $policyType,
])->save();
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
}
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
if (! in_array($policyType, $configurationTypes, true)) {
return;
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $configurationTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $configurationTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->first();
if (! $existingWrong) {
return;
}
$existingWrong->forceFill([
'policy_type' => $policyType,
])->save();
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
}
/**
* Re-fetch a single policy from Graph and update local metadata.
*/
public function syncPolicy(Tenant $tenant, Policy $policy): void
{
if (! $tenant->isActive()) {
throw new RuntimeException('Tenant is archived or inactive.');
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$this->graphLogger->logRequest('get_policy', [
'tenant' => $tenantIdentifier,
'policy_type' => $policy->policy_type,
'policy_id' => $policy->external_id,
'platform' => $policy->platform,
]);
try {
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $policy->platform,
]);
} catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable($throwable, [
'policy_type' => $policy->policy_type,
'policy_id' => $policy->external_id,
'tenant_id' => $tenant->id,
'tenant_identifier' => $tenantIdentifier,
]);
}
$this->graphLogger->logResponse('get_policy', $response, [
'tenant_id' => $tenant->id,
'tenant' => $tenantIdentifier,
'policy_type' => $policy->policy_type,
'policy_id' => $policy->external_id,
]);
if ($response->failed()) {
$message = $response->errors[0]['message'] ?? $response->data['error']['message'] ?? 'Graph request failed.';
throw new RuntimeException($message);
}
$payload = $response->data['payload'] ?? $response->data;
if (! is_array($payload)) {
throw new RuntimeException('Invalid Graph response payload.');
}
$displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name;
$platform = $payload['platform'] ?? $policy->platform;
$policy->forceFill([
'display_name' => $displayName,
'platform' => $platform,
'last_synced_at' => now(),
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
])->save();
}
}