Automated PR provided by Codex via Gitea API. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #482
323 lines
12 KiB
PHP
323 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\Providers\ProviderGateway;
|
|
use App\Support\OpsUx\RunFailureSanitizer;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use Illuminate\Support\Collection;
|
|
use InvalidArgumentException;
|
|
use Throwable;
|
|
|
|
final class GenericContentEvidenceCaptureService
|
|
{
|
|
public function __construct(
|
|
private readonly ResourceTypeRegistry $resourceTypes,
|
|
private readonly CoverageSourceContractResolver $contractResolver,
|
|
private readonly ProviderGateway $providerGateway,
|
|
private readonly GenericPayloadNormalizer $normalizer,
|
|
private readonly CoveragePayloadRedactor $redactor,
|
|
private readonly CoverageResourceUpserter $resourceUpserter,
|
|
private readonly CoverageEvidenceWriter $evidenceWriter,
|
|
private readonly CoverageCaptureOutcomeSummarizer $summarizer,
|
|
) {}
|
|
|
|
/**
|
|
* @param list<string>|null $canonicalTypes
|
|
* @return array{
|
|
* outcomes: list<array{canonical_type: string, outcome: string, item_count?: int, reason_code?: string|null, source_contract_key?: string|null}>,
|
|
* summary_counts: array<string, int>,
|
|
* run_outcome: string,
|
|
* failures: list<array{code: string, message: string, resource_type?: string}>
|
|
* }
|
|
*/
|
|
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<string>|null $canonicalTypes
|
|
* @return Collection<int, TenantConfigurationResourceType>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<array<string, mixed>>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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());
|
|
}
|
|
}
|