TenantAtlas/app/Services/Intune/PolicySyncService.php
ahmido 412dd7ad66 feat/017-policy-types-mam-endpoint-security-baselines (#23)
Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
2026-01-03 02:06:35 +00:00

416 lines
14 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);
}
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
return $policyType;
}
if ($this->isEnrollmentStatusPageItem($policyData)) {
return 'windowsEnrollmentStatusPage';
}
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 reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
return;
}
$enrollmentTypes = ['enrollmentRestriction', 'windowsEnrollmentStatusPage'];
$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();
}
}