TenantAtlas/app/Services/Intune/PolicySyncService.php
ahmido 4db8030f2a Spec 081: Provider connection cutover (#98)
Implements Spec 081 provider-connection cutover.

Highlights:
- Adds provider connection resolution + gating for operations/verification.
- Adds provider credential observer wiring.
- Updates Filament tenant verify flow to block with next-steps when provider connection isn’t ready.
- Adds spec docs under specs/081-provider-connection-cutover/ and extensive Spec081 test coverage.

Tests:
- vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantSetupTest.php
- Focused suites for ProviderConnections/Verification ran during implementation (see local logs).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #98
2026-02-08 11:28:51 +00:00

581 lines
20 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\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,
) {}
/**
* 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 = [];
$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;
}
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;
}
$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(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.');
}
$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;
$policy->forceFill([
'display_name' => $displayName,
'platform' => $platform,
'last_synced_at' => now(),
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
])->save();
}
/**
* @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);
}
}