TenantAtlas/apps/platform/app/Services/Intune/PolicySyncService.php
ahmido feeaadd5ad feat: add provider-missing policy visibility and restore continuity (#316)
## Summary
- separate provider-missing policy presence from local ignore semantics by introducing `missing_from_provider_at`
- update policy, backup, and restore surfaces so current-state capture stays honest while historical restore continuity remains available
- add focused sync, Filament, backup, restore, localization, and badge coverage for the new provider-missing behavior

## Scope
- policy sync and model truth
- policy resource visibility, badges, labels, and action gating
- backup/export eligibility and restore continuity messaging
- spec 261 artifacts and focused tests

## Validation
- feature-specific Pest coverage is included in the branch
- validation was not re-run as part of this commit/push/PR handoff

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #316
2026-05-01 20:18:27 +00:00

728 lines
26 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Audit\AuditActionId;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Support\Arr;
use RuntimeException;
use Throwable;
class PolicySyncService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphLogger $graphLogger,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
private readonly ?AuditLogger $auditLogger = null,
) {}
/**
* 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 = [];
$successfulPolicyTypes = [];
$observedExternalIdsByPolicyType = [];
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
return [
'synced' => [],
'failures' => $this->blockedFailuresForTypes($tenant, $types, $resolution->effectiveReasonCode(), $resolution->message, $resolution->connection),
];
}
$connection = $resolution->connection;
$tenantIdentifier = (string) $connection->entra_tenant_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->providerGateway()->listPolicies($connection, $policyType, [
'platform' => $platform,
'filter' => $filter,
]);
} catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable($throwable, [
'policy_type' => $policyType,
'tenant_id' => $tenant->id,
'tenant_identifier' => $tenantIdentifier,
'provider_connection_id' => (int) $connection->getKey(),
]);
}
$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;
}
$successfulPolicyTypes[$policyType] = true;
$observedExternalIdsByPolicyType[$policyType] ??= [];
foreach ($response->data as $policyData) {
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
if ($externalId === null) {
continue;
}
$externalId = (string) $externalId;
$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') {
continue;
}
}
$observedExternalIdsByPolicyType[$policyType][] = $externalId;
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
tenant: $tenant,
externalId: $externalId,
policyType: $policyType,
);
$this->reclassifyConfigurationPoliciesIfNeeded(
tenant: $tenant,
externalId: $externalId,
policyType: $policyType,
);
$policy = Policy::query()->firstOrNew([
'tenant_id' => $tenant->id,
'external_id' => $externalId,
'policy_type' => $policyType,
]);
$wasProviderMissing = $policy->exists && $policy->missing_from_provider_at !== null;
$policy->forceFill([
'workspace_id' => $tenant->workspace_id,
'display_name' => $displayName,
'platform' => $policyPlatform,
'last_synced_at' => now(),
'missing_from_provider_at' => null,
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
$synced[] = $policy->id;
}
}
$this->markProviderMissingPolicies(
tenant: $tenant,
policyTypes: array_keys($successfulPolicyTypes),
observedExternalIdsByPolicyType: $observedExternalIdsByPolicyType,
);
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;
}
$templateFamily = $templateReference['templateFamily'] ?? null;
return is_string($templateFamily) && str_starts_with(strtolower(trim($templateFamily)), 'endpointsecurity');
}
private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
{
$templateReference = $policyData['templateReference'] ?? null;
if (! is_array($templateReference)) {
return false;
}
$templateFamily = $templateReference['templateFamily'] ?? null;
return is_string($templateFamily) && strcasecmp(trim($templateFamily), 'securityBaseline') === 0;
}
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(Tenant $tenant, string $externalId, string $policyType): void
{
$enrollmentTypes = [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
];
if (! in_array($policyType, $enrollmentTypes, true)) {
return;
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
$this->markSiblingPoliciesProviderMissing(
tenant: $tenant,
externalId: $externalId,
policyTypes: $enrollmentTypes,
exceptPolicyType: $policyType,
);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->whereIn('policy_type', $enrollmentTypes)
->where('policy_type', '!=', $policyType)
->first();
if (! $existingWrong) {
return;
}
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
$existingWrong->forceFill([
'policy_type' => $policyType,
'missing_from_provider_at' => null,
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $existingWrong,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
}
private function reclassifyConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
{
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
if (! in_array($policyType, $configurationTypes, true)) {
return;
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
$this->markSiblingPoliciesProviderMissing(
tenant: $tenant,
externalId: $externalId,
policyTypes: $configurationTypes,
exceptPolicyType: $policyType,
);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->whereIn('policy_type', $configurationTypes)
->where('policy_type', '!=', $policyType)
->first();
if (! $existingWrong) {
return;
}
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
$existingWrong->forceFill([
'policy_type' => $policyType,
'missing_from_provider_at' => null,
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $existingWrong,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
}
/**
* @param array<int, string> $policyTypes
*/
private function markSiblingPoliciesProviderMissing(Tenant $tenant, string $externalId, array $policyTypes, string $exceptPolicyType): void
{
$timestamp = now();
Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->whereIn('policy_type', $policyTypes)
->where('policy_type', '!=', $exceptPolicyType)
->whereNull('missing_from_provider_at')
->get()
->each(function (Policy $policy) use ($tenant, $timestamp): void {
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingDetected,
transitionAt: $timestamp,
);
});
}
/**
* @param array<int, string> $policyTypes
* @param array<string, array<int, string>> $observedExternalIdsByPolicyType
*/
private function markProviderMissingPolicies(Tenant $tenant, array $policyTypes, array $observedExternalIdsByPolicyType): void
{
foreach ($policyTypes as $policyType) {
if (! is_string($policyType) || $policyType === '') {
continue;
}
$observedExternalIds = array_values(array_unique(array_filter(
array_map('strval', $observedExternalIdsByPolicyType[$policyType] ?? []),
static fn (string $externalId): bool => $externalId !== '',
)));
$query = Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', $policyType)
->whereNull('missing_from_provider_at');
if ($observedExternalIds !== []) {
$query->whereNotIn('external_id', $observedExternalIds);
}
$timestamp = now();
$query->get()
->each(function (Policy $policy) use ($tenant, $timestamp): void {
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingDetected,
transitionAt: $timestamp,
);
});
}
}
private function auditProviderPresenceTransition(
Tenant $tenant,
Policy $policy,
AuditActionId $action,
mixed $transitionAt = null,
): void {
$transitionAt ??= now();
$this->auditLogger()->log(
tenant: $tenant,
action: $action,
context: [
'metadata' => [
'policy_id' => (int) $policy->getKey(),
'external_id' => (string) $policy->external_id,
'policy_type' => (string) $policy->policy_type,
'transition_at' => method_exists($transitionAt, 'toIso8601String')
? $transitionAt->toIso8601String()
: (string) $transitionAt,
'source' => 'policy_sync',
],
],
resourceType: 'policy',
resourceId: (string) $policy->getKey(),
targetLabel: (string) $policy->display_name,
status: 'success',
);
}
private function auditLogger(): AuditLogger
{
return $this->auditLogger ?? app(AuditLogger::class);
}
/**
* 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.');
}
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
throw new RuntimeException(sprintf('[%s] %s', $reasonCode, $reasonMessage));
}
$connection = $resolution->connection;
$tenantIdentifier = (string) $connection->entra_tenant_id;
$this->graphLogger->logRequest('get_policy', [
'tenant' => $tenantIdentifier,
'policy_type' => $policy->policy_type,
'policy_id' => $policy->external_id,
'platform' => $policy->platform,
]);
try {
$response = $this->providerGateway()->getPolicy(
connection: $connection,
policyType: $policy->policy_type,
policyId: $policy->external_id,
options: [
'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,
'provider_connection_id' => (int) $connection->getKey(),
]);
}
$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;
$wasProviderMissing = $policy->missing_from_provider_at !== null;
$policy->forceFill([
'display_name' => $displayName,
'platform' => $platform,
'last_synced_at' => now(),
'missing_from_provider_at' => null,
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
}
/**
* @param array<int, array{type: string, platform?: string|null, filter?: string|null}> $types
* @return array<int, array{policy_type: string, status: int|null, errors: array, meta: array}>
*/
private function blockedFailuresForTypes(
Tenant $tenant,
array $types,
string $reasonCode,
?string $reasonMessage = null,
?ProviderConnection $connection = null,
): array {
$knownReasonCode = ProviderReasonCodes::isKnown($reasonCode)
? $reasonCode
: ProviderReasonCodes::UnknownError;
$message = sprintf(
'[%s] %s',
$knownReasonCode,
$reasonMessage ?? 'Provider connection is not configured.',
);
$nextSteps = $this->nextStepsRegistry()->forReason($tenant, $knownReasonCode, $connection);
$failures = [];
foreach ($types as $typeConfig) {
if (! is_array($typeConfig)) {
continue;
}
$policyType = $typeConfig['type'] ?? null;
if (! is_string($policyType) || $policyType === '') {
continue;
}
$failures[] = [
'policy_type' => $policyType,
'status' => null,
'errors' => [['message' => $message]],
'meta' => [
'reason_code' => $knownReasonCode,
'next_steps' => $nextSteps,
],
];
}
return $failures;
}
private function providerConnections(): ProviderConnectionResolver
{
return $this->providerConnections ?? app(ProviderConnectionResolver::class);
}
private function providerGateway(): ProviderGateway
{
return $this->providerGateway ?? app(ProviderGateway::class);
}
private function nextStepsRegistry(): ProviderNextStepsRegistry
{
return $this->nextStepsRegistry ?? app(ProviderNextStepsRegistry::class);
}
}