where('tenant_id', $tenant->getKey()) ->where('selection_key', $selectionKey) ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) ->orderByDesc('id') ->first(); if ($existing instanceof EntraGroupSyncRun) { return $existing; } $run = EntraGroupSyncRun::create([ 'tenant_id' => $tenant->getKey(), 'selection_key' => $selectionKey, 'status' => EntraGroupSyncRun::STATUS_PENDING, 'initiator_user_id' => $user->getKey(), ]); dispatch(new \App\Jobs\EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: $selectionKey, slotKey: null, runId: (int) $run->getKey(), )); return $run; } /** * @return array{ * pages_fetched:int, * items_observed_count:int, * items_upserted_count:int, * error_count:int, * safety_stop_triggered:bool, * safety_stop_reason:?string, * error_code:?string, * error_category:?string, * error_summary:?string * } */ public function sync(Tenant $tenant, EntraGroupSyncRun $run): array { $nowUtc = CarbonImmutable::now('UTC'); $policyType = $this->contracts->directoryGroupsPolicyType(); $path = $this->contracts->directoryGroupsListPath(); $contract = $this->contracts->get($policyType); $query = []; if (isset($contract['allowed_select']) && is_array($contract['allowed_select']) && $contract['allowed_select'] !== []) { $query['$select'] = $contract['allowed_select']; } $pageSize = (int) config('directory_groups.page_size', 999); if ($pageSize > 0) { $query['$top'] = $pageSize; } $sanitized = $this->contracts->sanitizeQuery($policyType, $query); $query = $sanitized['query']; $maxPages = (int) config('directory_groups.safety_stop.max_pages', 200); $maxRuntimeSeconds = (int) config('directory_groups.safety_stop.max_runtime_seconds', 600); $deadline = $nowUtc->addSeconds(max(1, $maxRuntimeSeconds)); $pagesFetched = 0; $observed = 0; $upserted = 0; $safetyStopTriggered = false; $safetyStopReason = null; $errorCode = null; $errorCategory = null; $errorSummary = null; $errorCount = 0; $options = $tenant->graphOptions(); $useQuery = $query; $nextPath = $path; while ($nextPath) { if (CarbonImmutable::now('UTC')->greaterThan($deadline)) { $safetyStopTriggered = true; $safetyStopReason = 'runtime_exceeded'; break; } if ($pagesFetched >= $maxPages) { $safetyStopTriggered = true; $safetyStopReason = 'max_pages_exceeded'; break; } $response = $this->requestWithRetry('GET', $nextPath, $options + ['query' => $useQuery]); if ($response->failed()) { [$errorCode, $errorCategory, $errorSummary] = $this->categorizeError($response); $errorCount = 1; break; } $pagesFetched++; $data = $response->data; $pageItems = $data['value'] ?? (is_array($data) ? $data : []); if (is_array($pageItems)) { foreach ($pageItems as $item) { if (! is_array($item)) { continue; } $entraId = $item['id'] ?? null; if (! is_string($entraId) || $entraId === '') { continue; } $displayName = $item['displayName'] ?? null; $groupTypes = $item['groupTypes'] ?? null; $values = [ 'display_name' => is_string($displayName) ? $displayName : $entraId, 'group_types' => is_array($groupTypes) ? $groupTypes : [], 'security_enabled' => (bool) ($item['securityEnabled'] ?? false), 'mail_enabled' => (bool) ($item['mailEnabled'] ?? false), 'last_seen_at' => $nowUtc, ]; EntraGroup::query()->updateOrCreate([ 'tenant_id' => $tenant->getKey(), 'entra_id' => $entraId, ], $values); $observed++; $upserted++; } } $nextLink = is_array($data) ? ($data['@odata.nextLink'] ?? null) : null; if (! is_string($nextLink) || $nextLink === '') { break; } $nextPath = $this->stripGraphBaseUrl($nextLink); $useQuery = []; } $retentionDays = (int) config('directory_groups.retention_days', 90); if ($retentionDays > 0) { $cutoff = $nowUtc->subDays($retentionDays); EntraGroup::query() ->where('tenant_id', $tenant->getKey()) ->whereNotNull('last_seen_at') ->where('last_seen_at', '<', $cutoff) ->delete(); } return [ 'pages_fetched' => $pagesFetched, 'items_observed_count' => $observed, 'items_upserted_count' => $upserted, 'error_count' => $errorCount, 'safety_stop_triggered' => $safetyStopTriggered, 'safety_stop_reason' => $safetyStopReason, 'error_code' => $errorCode, 'error_category' => $errorCategory, 'error_summary' => $errorSummary, ]; } private function requestWithRetry(string $method, string $path, array $options): GraphResponse { $maxRetries = (int) config('directory_groups.safety_stop.max_retries', 8); $maxRetries = max(0, $maxRetries); for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { $response = $this->graph->request($method, $path, $options); if ($response->successful()) { return $response; } $status = (int) ($response->status ?? 0); if (! in_array($status, [429, 503], true) || $attempt >= $maxRetries) { return $response; } $baseDelaySeconds = min(30, 1 << $attempt); $jitterMillis = random_int(0, 250); usleep(($baseDelaySeconds * 1000 + $jitterMillis) * 1000); } return new GraphResponse(success: false, data: [], status: 500, errors: [['message' => 'Retry loop exceeded']]); } /** * @return array{0:string,1:string,2:string} */ private function categorizeError(GraphResponse $response): array { $status = (int) ($response->status ?? 0); if (in_array($status, [401, 403], true)) { return ['permission_denied', 'permission', 'Graph permission denied for groups listing.']; } if ($status === 429) { return ['throttled', 'throttling', 'Graph throttled the groups listing request.']; } if (in_array($status, [500, 502, 503, 504], true)) { return ['graph_unavailable', 'transient', 'Graph returned a transient server error.']; } return ['graph_request_failed', 'unknown', 'Graph request failed.']; } private function stripGraphBaseUrl(string $nextLink): string { $base = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/') .'/'.trim((string) config('graph.version', 'v1.0'), '/'); if (str_starts_with($nextLink, $base)) { return ltrim((string) substr($nextLink, strlen($base)), '/'); } return ltrim($nextLink, '/'); } }