$selectionPayload */ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun { $normalizedSelection = $this->selectionHasher->normalize($selectionPayload); $normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']); $selectionHash = $this->selectionHasher->hash($normalizedSelection); $now = CarbonImmutable::now('UTC'); $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); if (! $globalSlot instanceof Lock) { return $this->createSkippedRun( tenant: $tenant, selectionHash: $selectionHash, selectionPayload: $normalizedSelection, now: $now, errorCode: 'concurrency_limit_global', ); } $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); if (! $tenantSlot instanceof Lock) { $globalSlot->release(); return $this->createSkippedRun( tenant: $tenant, selectionHash: $selectionHash, selectionPayload: $normalizedSelection, now: $now, errorCode: 'concurrency_limit_tenant', ); } $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); if (! $selectionLock->get()) { $tenantSlot->release(); $globalSlot->release(); return $this->createSkippedRun( tenant: $tenant, selectionHash: $selectionHash, selectionPayload: $normalizedSelection, now: $now, errorCode: 'lock_contended', ); } $run = InventorySyncRun::query()->create([ 'tenant_id' => $tenant->getKey(), 'selection_hash' => $selectionHash, 'selection_payload' => $normalizedSelection, 'status' => InventorySyncRun::STATUS_RUNNING, 'had_errors' => false, 'error_codes' => [], 'error_context' => null, 'started_at' => $now, 'finished_at' => null, 'items_observed_count' => 0, 'items_upserted_count' => 0, 'errors_count' => 0, ]); try { return $this->executeRun($run, $tenant, $normalizedSelection); } finally { $selectionLock->release(); $tenantSlot->release(); $globalSlot->release(); } } /** * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection */ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection): InventorySyncRun { $observed = 0; $upserted = 0; $errors = 0; $errorCodes = []; $hadErrors = false; try { $typesConfig = $this->supportedTypeConfigByType(); foreach ($normalizedSelection['policy_types'] as $policyType) { $typeConfig = $typesConfig[$policyType] ?? null; if (! is_array($typeConfig)) { continue; } $response = $this->listPoliciesWithRetry($policyType, [ 'tenant' => $tenant->tenant_id ?? $tenant->external_id, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $typeConfig['platform'] ?? null, 'filter' => $typeConfig['filter'] ?? null, ]); if ($response->failed()) { $hadErrors = true; $errors++; $errorCodes[] = $this->mapGraphFailureToErrorCode($response); continue; } foreach ($response->data as $policyData) { if (! is_array($policyData)) { continue; } $externalId = $policyData['id'] ?? $policyData['external_id'] ?? null; if (! is_string($externalId) || $externalId === '') { continue; } $observed++; $displayName = $policyData['displayName'] ?? $policyData['name'] ?? null; $displayName = is_string($displayName) ? $displayName : null; $scopeTagIds = $policyData['roleScopeTagIds'] ?? null; $assignmentTargetCount = null; $assignments = $policyData['assignments'] ?? null; if (is_array($assignments)) { $assignmentTargetCount = count($assignments); } $meta = $this->metaSanitizer->sanitize([ 'odata_type' => $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null, 'etag' => $policyData['@odata.etag'] ?? null, 'scope_tag_ids' => is_array($scopeTagIds) ? $scopeTagIds : null, 'assignment_target_count' => $assignmentTargetCount, 'warnings' => [], ]); $item = InventoryItem::query()->updateOrCreate( [ 'tenant_id' => $tenant->getKey(), 'policy_type' => $policyType, 'external_id' => $externalId, ], [ 'display_name' => $displayName, 'category' => $typeConfig['category'] ?? null, 'platform' => $typeConfig['platform'] ?? null, 'meta_jsonb' => $meta, 'last_seen_at' => now(), 'last_seen_run_id' => $run->getKey(), ] ); $upserted++; // Extract dependencies if requested in selection $includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true); if ($includeDeps) { app(\App\Services\Inventory\DependencyExtractionService::class) ->extractForPolicyData($item, $policyData); } } } $status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS; $run->update([ 'status' => $status, 'had_errors' => $hadErrors, 'error_codes' => array_values(array_unique($errorCodes)), 'error_context' => null, 'items_observed_count' => $observed, 'items_upserted_count' => $upserted, 'errors_count' => $errors, 'finished_at' => CarbonImmutable::now('UTC'), ]); return $run->refresh(); } catch (Throwable $throwable) { $run->update([ 'status' => InventorySyncRun::STATUS_FAILED, 'had_errors' => true, 'error_codes' => ['unexpected_exception'], 'error_context' => $this->safeErrorContext($throwable), 'items_observed_count' => $observed, 'items_upserted_count' => $upserted, 'errors_count' => $errors + 1, 'finished_at' => CarbonImmutable::now('UTC'), ]); return $run->refresh(); } } /** * @return array> */ private function supportedTypeConfigByType(): array { /** @var array> $supported */ $supported = config('tenantpilot.supported_policy_types', []); $byType = []; foreach ($supported as $config) { $type = $config['type'] ?? null; if (is_string($type) && $type !== '') { $byType[$type] = $config; } } return $byType; } private function selectionLockKey(Tenant $tenant, string $selectionHash): string { return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash); } /** * @param array $selectionPayload */ private function createSkippedRun( Tenant $tenant, string $selectionHash, array $selectionPayload, CarbonImmutable $now, string $errorCode, ): InventorySyncRun { return InventorySyncRun::query()->create([ 'tenant_id' => $tenant->getKey(), 'selection_hash' => $selectionHash, 'selection_payload' => $selectionPayload, 'status' => InventorySyncRun::STATUS_SKIPPED, 'had_errors' => true, 'error_codes' => [$errorCode], 'error_context' => null, 'started_at' => $now, 'finished_at' => $now, 'items_observed_count' => 0, 'items_upserted_count' => 0, 'errors_count' => 0, ]); } private function mapGraphFailureToErrorCode(GraphResponse $response): string { $status = (int) ($response->status ?? 0); return match ($status) { 403 => 'graph_forbidden', 429 => 'graph_throttled', 503 => 'graph_transient', default => 'graph_transient', }; } private function listPoliciesWithRetry(string $policyType, array $options): GraphResponse { $maxAttempts = 3; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $response = $this->graphClient->listPolicies($policyType, $options); if (! $response->failed()) { return $response; } $status = (int) ($response->status ?? 0); if (! in_array($status, [429, 503], true)) { return $response; } if ($attempt >= $maxAttempts) { return $response; } $baseMs = 250 * (2 ** ($attempt - 1)); $jitterMs = random_int(0, 250); usleep(($baseMs + $jitterMs) * 1000); } return new GraphResponse(false, [], null, ['error' => ['code' => 'unexpected_exception', 'message' => 'retry loop failed']]); } /** * @return array */ private function safeErrorContext(Throwable $throwable): array { $message = $throwable->getMessage(); $message = preg_replace('/Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/', 'Bearer [REDACTED]', (string) $message); $message = mb_substr((string) $message, 0, 500); return [ 'exception_class' => get_class($throwable), 'message' => $message, ]; } }