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
500 lines
17 KiB
PHP
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();
|
|
}
|
|
}
|