|null $canonicalTypes * @return array{ * outcomes: list, * summary_counts: array, * run_outcome: string, * failures: list * } */ public function capture( ManagedEnvironment $tenant, ProviderConnection $providerConnection, OperationRun $operationRun, ?array $canonicalTypes = null, bool $allowBetaCapture = false, ): array { $this->assertScopedExecution($tenant, $providerConnection, $operationRun); $outcomes = []; foreach ($this->selectedResourceTypes($canonicalTypes) as $resourceType) { $decision = $this->contractResolver->resolve($resourceType, allowBetaCapture: $allowBetaCapture); if (! $decision->capturable()) { $outcomes[] = $this->outcomeRow($resourceType, $decision->outcome, $decision->reasonCode, 0, $decision->contractKey); continue; } try { $response = $this->providerGateway->listPolicies( $providerConnection, (string) $decision->contractKey, $this->graphListOptions($operationRun), ); } catch (Throwable $e) { $outcomes[] = $this->outcomeRow($resourceType, CaptureOutcome::Failed, $this->exceptionReasonCode($e), 0, $decision->contractKey); continue; } if ($response->failed()) { $outcomes[] = $this->failedResponseOutcome($resourceType, $response, $decision); continue; } try { $capturedItems = $this->captureResponseItems( tenant: $tenant, providerConnection: $providerConnection, operationRun: $operationRun, resourceType: $resourceType, decision: $decision, response: $response, ); $outcomes[] = $this->outcomeRow($resourceType, CaptureOutcome::Captured, null, $capturedItems, $decision->contractKey); } catch (Throwable $e) { $outcomes[] = $this->outcomeRow($resourceType, CaptureOutcome::Failed, $this->exceptionReasonCode($e), 0, $decision->contractKey); } } return [ 'outcomes' => $outcomes, ...$this->summarizer->summarize($outcomes), ]; } /** * @param list|null $canonicalTypes * @return Collection */ private function selectedResourceTypes(?array $canonicalTypes): Collection { $selected = collect($canonicalTypes ?? ResourceTypeRegistry::defaultCanonicalTypes()) ->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '') ->map(static fn (string $type): string => trim($type)) ->unique() ->values() ->all(); return $this->resourceTypes->active() ->filter(static fn (TenantConfigurationResourceType $resourceType): bool => in_array((string) $resourceType->canonical_type, $selected, true)) ->sortBy(static fn (TenantConfigurationResourceType $resourceType): string => (string) $resourceType->canonical_type) ->values(); } /** * @return array */ private function graphListOptions(OperationRun $operationRun): array { return [ 'client_request_id' => sprintf('tenant-config-capture-%d', (int) $operationRun->getKey()), 'top' => 999, ]; } private function assertScopedExecution( ManagedEnvironment $tenant, ProviderConnection $providerConnection, OperationRun $operationRun, ): void { if ((int) $providerConnection->workspace_id !== (int) $tenant->workspace_id || (int) $providerConnection->managed_environment_id !== (int) $tenant->getKey() ) { throw new InvalidArgumentException('Provider connection does not belong to the managed environment scope.'); } if ((int) $operationRun->workspace_id !== (int) $tenant->workspace_id || (int) $operationRun->managed_environment_id !== (int) $tenant->getKey() ) { throw new InvalidArgumentException('Operation run does not belong to the managed environment scope.'); } if ((string) $operationRun->type !== OperationRunType::TenantConfigurationCapture->value) { throw new InvalidArgumentException('Operation run type is not valid for tenant configuration capture.'); } if ((int) data_get($operationRun->context, 'target_scope.workspace_id') !== (int) $tenant->workspace_id || (int) data_get($operationRun->context, 'target_scope.managed_environment_id') !== (int) $tenant->getKey() || (int) data_get($operationRun->context, 'target_scope.provider_connection_id') !== (int) $providerConnection->getKey() ) { throw new InvalidArgumentException('Operation run target scope does not match the capture provider scope.'); } } private function failedResponseOutcome( TenantConfigurationResourceType $resourceType, GraphResponse $response, CoverageSourceContractDecision $decision, ): array { $status = (int) ($response->status ?? 0); if (in_array($status, [401, 403], true)) { return $this->outcomeRow( resourceType: $resourceType, outcome: CaptureOutcome::BlockedPermission, reasonCode: 'graph_permission_blocked', itemCount: 0, sourceContractKey: $decision->contractKey, ); } return $this->outcomeRow( resourceType: $resourceType, outcome: CaptureOutcome::Failed, reasonCode: 'graph_response_failed_'.$status, itemCount: 0, sourceContractKey: $decision->contractKey, ); } private function captureResponseItems( ManagedEnvironment $tenant, ProviderConnection $providerConnection, OperationRun $operationRun, TenantConfigurationResourceType $resourceType, CoverageSourceContractDecision $decision, GraphResponse $response, ): int { $captured = 0; $volatileFields = $this->volatileFields($decision); $permissionContext = $this->permissionContext($providerConnection); foreach ($this->responseItems($response) as $item) { $normalizedPayload = $this->normalizer->normalize($item, $volatileFields); $payloadHash = $this->normalizer->payloadHash($normalizedPayload); $resource = $this->resourceUpserter->upsert( tenant: $tenant, providerConnection: $providerConnection, resourceType: $resourceType, payload: $item, sourceMetadata: $decision->sourceMetadata, ); $this->evidenceWriter->append( resource: $resource, resourceType: $resourceType, providerConnection: $providerConnection, operationRun: $operationRun, decision: $decision, rawPayload: $item, normalizedPayload: $normalizedPayload, payloadHash: $payloadHash, permissionContext: $permissionContext, ); $captured++; } return $captured; } /** * @return list> */ private function responseItems(GraphResponse $response): array { $data = $response->data; if (array_is_list($data)) { return array_values(array_filter($data, static fn (mixed $item): bool => is_array($item))); } if (isset($data['value']) && is_array($data['value'])) { return array_values(array_filter($data['value'], static fn (mixed $item): bool => is_array($item))); } if (isset($data['id'])) { return [$data]; } return []; } /** * @return list */ private function volatileFields(CoverageSourceContractDecision $decision): array { $volatileFields = $decision->contract['volatile_fields'] ?? []; if (! is_array($volatileFields)) { return []; } return collect($volatileFields) ->filter(static fn (mixed $field): bool => is_string($field) && trim($field) !== '') ->map(static fn (string $field): string => trim($field)) ->values() ->all(); } /** * @return array */ private function permissionContext(ProviderConnection $providerConnection): array { return $this->redactor->redact([ 'provider_connection_id' => (int) $providerConnection->getKey(), 'provider' => (string) $providerConnection->provider, 'connection_type' => $this->stringValue($providerConnection->connection_type), 'consent_status' => $this->stringValue($providerConnection->consent_status), 'verification_status' => $this->stringValue($providerConnection->verification_status), 'scopes_granted' => is_array($providerConnection->scopes_granted) ? $providerConnection->scopes_granted : [], ]); } private function stringValue(mixed $value): ?string { if ($value instanceof \BackedEnum) { return (string) $value->value; } if (is_scalar($value)) { $value = trim((string) $value); return $value !== '' ? $value : null; } return null; } /** * @return array{canonical_type: string, outcome: string, item_count: int, reason_code?: string|null, source_contract_key?: string|null} */ private function outcomeRow( TenantConfigurationResourceType $resourceType, CaptureOutcome $outcome, ?string $reasonCode = null, int $itemCount = 0, ?string $sourceContractKey = null, ): array { return [ 'canonical_type' => (string) $resourceType->canonical_type, 'outcome' => $outcome->value, 'item_count' => max(0, $itemCount), 'reason_code' => $reasonCode, 'source_contract_key' => $sourceContractKey, ]; } private function exceptionReasonCode(Throwable $e): string { return RunFailureSanitizer::normalizeReasonCode($e->getMessage()); } }