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
581 lines
20 KiB
PHP
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);
|
|
}
|
|
}
|