TenantAtlas/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php
ahmido ca0f54614d feat: add generic content-backed coverage capture (#482)
Automated PR provided by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #482
2026-06-25 19:55:52 +00:00

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());
}
}