TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php
ahmido a23131cdbc feat: add Exchange evidence capture adapter guard (#501)
Summary: add Spec 434 Exchange PowerShell evidence capture adapter and prerequisite/identity/content-only guards; cap Exchange evidence at content_backed while preserving Graph capture. Validation: php artisan test --filter=Spec434 --compact; ./vendor/bin/pint --dirty --test; git diff --cached --check. Product Surface: N/A - no rendered UI surface changed; no deployment impact.
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #501
2026-07-08 10:46:05 +00:00

382 lines
14 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\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\TenantConfiguration\CaptureOutcome;
final class ExchangePowerShellEvidenceCaptureAdapter
{
public const string OUTCOME_PREREQUISITE_BLOCKED = 'exchange_capture_prerequisite_blocked';
public const string OUTCOME_IDENTITY_BLOCKED = 'exchange_capture_identity_blocked';
public const string OUTCOME_CONTENT_LEVEL_BLOCKED = 'exchange_capture_content_level_blocked';
public const string OUTCOME_EMPTY_COLLECTION = 'exchange_capture_empty_collection';
public const string OUTCOME_SANITIZED_FAILURE = 'exchange_capture_sanitized_failure';
public function __construct(
private readonly ExchangePowerShellCaptureEligibilityGate $eligibility,
private readonly ExchangePowerShellIdentityEvidenceGate $identityGate,
private readonly ExchangePowerShellContentOnlyEvidenceGuard $contentOnlyGuard,
private readonly GenericPayloadNormalizer $genericNormalizer,
private readonly ExchangeTeamsComparablePayloadNormalizer $exchangeTeamsNormalizer,
private readonly CoveragePayloadRedactor $redactor,
private readonly CoverageResourceUpserter $resourceUpserter,
private readonly CoverageEvidenceWriter $evidenceWriter,
) {}
/**
* @return array{
* canonical_type: string,
* outcome: string,
* adapter_outcome: string,
* item_count: int,
* reason_code?: string|null,
* source_contract_key?: string|null,
* evidence_ids: list<int>,
* summary_counts: array<string, int>
* }
*/
public function capture(
ManagedEnvironment $tenant,
ProviderConnection $providerConnection,
OperationRun $operationRun,
string $canonicalType,
ExchangePowerShellInvocationResult $runnerResult,
): array {
$canonicalType = trim($canonicalType);
$eligibility = $this->eligibility->evaluate($tenant, $providerConnection, $operationRun, $canonicalType, $runnerResult);
if (($eligibility['allowed'] ?? false) !== true) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: $eligibility['outcome'] ?? CaptureOutcome::BlockedUnsupported,
adapterOutcome: self::OUTCOME_PREREQUISITE_BLOCKED,
itemCount: 0,
reasonCode: $eligibility['reason_code'] ?? 'exchange_capture_prerequisite_blocked',
sourceContractKey: null,
evidenceIds: [],
summaryCounts: $summaryCounts,
);
}
/** @var TenantConfigurationResourceType $resourceType */
$resourceType = $eligibility['resource_type'];
/** @var CoverageSourceContractDecision $sourceDecision */
$sourceDecision = $eligibility['decision'];
/** @var array<string, mixed> $contract */
$contract = $eligibility['contract'];
/** @var list<array<string, mixed>> $items */
$items = $runnerResult->collection;
if ($items === []) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::Captured,
adapterOutcome: self::OUTCOME_EMPTY_COLLECTION,
itemCount: 0,
reasonCode: self::OUTCOME_EMPTY_COLLECTION,
sourceContractKey: $sourceDecision->contractKey,
evidenceIds: [],
summaryCounts: $summaryCounts,
);
}
$decision = $this->capturableDecision($sourceDecision, $contract);
$identity = $this->identityGate->evaluateCollection(
tenant: $tenant,
providerConnection: $providerConnection,
resourceType: $resourceType,
items: $items,
sourceMetadata: $decision->sourceMetadata,
);
if (($identity['allowed'] ?? false) !== true) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
adapterOutcome: self::OUTCOME_IDENTITY_BLOCKED,
itemCount: 0,
reasonCode: $identity['reason_code'] ?? self::OUTCOME_IDENTITY_BLOCKED,
sourceContractKey: $sourceDecision->contractKey,
evidenceIds: [],
summaryCounts: $summaryCounts,
);
}
$evidenceIds = [];
$volatileFields = $this->volatileFields($contract);
$permissionContext = $this->permissionContext($providerConnection);
foreach ($items as $item) {
$normalizedPayload = $this->normalizedPayload($resourceType, $item, $volatileFields, $decision);
$payloadHash = $this->genericNormalizer->payloadHash($normalizedPayload);
$resource = $this->resourceUpserter->upsert(
tenant: $tenant,
providerConnection: $providerConnection,
resourceType: $resourceType,
payload: $item,
sourceMetadata: $decision->sourceMetadata,
);
$evidence = $this->evidenceWriter->append(
resource: $resource,
resourceType: $resourceType,
providerConnection: $providerConnection,
operationRun: $operationRun,
decision: $decision,
rawPayload: $item,
normalizedPayload: $normalizedPayload,
payloadHash: $payloadHash,
permissionContext: $permissionContext,
maximumCoverageLevel: $this->contentOnlyGuard->maximumCoverageLevel(),
);
$this->contentOnlyGuard->assertContentOnly($evidence, $resource->refresh());
$evidenceIds[] = (int) $evidence->getKey();
}
$summaryCounts = $this->successSummaryCounts(count($evidenceIds));
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::Captured,
adapterOutcome: CaptureOutcome::Captured->value,
itemCount: count($evidenceIds),
reasonCode: null,
sourceContractKey: $decision->contractKey,
evidenceIds: $evidenceIds,
summaryCounts: $summaryCounts,
);
}
/**
* @param array<string, mixed> $contract
*/
private function capturableDecision(CoverageSourceContractDecision $sourceDecision, array $contract): CoverageSourceContractDecision
{
$commandName = (string) $contract['command_name'];
$sourceEndpoint = ExchangePowerShellCommandContracts::SOURCE_SURFACE.':'.$commandName;
$sourceMetadata = array_filter([
...$sourceDecision->sourceMetadata,
'source_endpoint' => $sourceEndpoint,
'source_contract_state' => CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE,
'capture_adapter' => 'exchange_powershell_evidence_capture_adapter',
'capture_eligibility_state' => 'content_backed_only',
'content_level_maximum' => 'content_backed',
'evidence_promotion_allowed' => false,
'customer_claims_allowed' => false,
'restore_allowed' => false,
'certification_allowed' => false,
], static fn (mixed $value): bool => $value !== null && $value !== '');
return new CoverageSourceContractDecision(
canonicalType: $sourceDecision->canonicalType,
outcome: CaptureOutcome::Captured,
contractKey: $sourceDecision->contractKey,
sourceEndpoint: $sourceEndpoint,
sourceVersion: $sourceDecision->sourceVersion,
sourceSchemaHash: $sourceDecision->sourceSchemaHash,
reasonCode: null,
sourceContractState: CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE,
contract: $contract,
sourceMetadata: $sourceMetadata,
);
}
/**
* @param array<string, mixed> $payload
* @param list<string> $volatileFields
* @return array<string, mixed>
*/
private function normalizedPayload(
TenantConfigurationResourceType $resourceType,
array $payload,
array $volatileFields,
CoverageSourceContractDecision $decision,
): array {
$canonicalType = (string) $resourceType->canonical_type;
if ($this->exchangeTeamsNormalizer->supports($canonicalType)) {
$normalized = $this->redactedPayload($this->exchangeTeamsNormalizer->normalize($canonicalType, $payload, $volatileFields));
$source = is_array($normalized['source'] ?? null) ? $normalized['source'] : [];
$normalized['source'] = array_filter([
...$source,
'source_contract_key' => $decision->contractKey,
'source_endpoint' => $decision->sourceEndpoint,
'source_version' => $decision->sourceVersion,
'source_schema_hash' => $decision->sourceSchemaHash,
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
], static fn (mixed $value): bool => $value !== null && $value !== '');
return $normalized;
}
$normalized = $this->genericNormalizer->normalize($payload, $volatileFields);
$normalized['source'] = array_filter([
'source_contract_key' => $decision->contractKey,
'source_endpoint' => $decision->sourceEndpoint,
'source_version' => $decision->sourceVersion,
'source_schema_hash' => $decision->sourceSchemaHash,
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
], static fn (mixed $value): bool => $value !== null && $value !== '');
return $this->redactedPayload($normalized);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function redactedPayload(array $payload): array
{
$redacted = $this->redactor->redact($payload);
return is_array($redacted) ? $redacted : [];
}
/**
* @param array<string, mixed> $contract
* @return list<string>
*/
private function volatileFields(array $contract): array
{
$fields = data_get($contract, 'response_shape.volatile_fields', []);
if (! is_array($fields)) {
return [];
}
return array_values(array_filter(
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
static fn (string $field): bool => $field !== '',
));
}
/**
* @return array<string, mixed>
*/
private function permissionContext(ProviderConnection $providerConnection): array
{
return $this->redactedPayload([
'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),
]);
}
/**
* @return array<string, int>
*/
private function zeroSummaryCounts(): array
{
return [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 0,
'items' => 0,
];
}
/**
* @return array<string, int>
*/
private function successSummaryCounts(int $count): array
{
$count = max(0, $count);
return [
'total' => $count,
'processed' => $count,
'succeeded' => $count,
'failed' => 0,
'skipped' => 0,
'items' => $count,
];
}
/**
* @param array<string, int> $summaryCounts
*/
private function recordSummaryCounts(OperationRun $operationRun, array $summaryCounts): void
{
$operationRun->forceFill([
'summary_counts' => SummaryCountsNormalizer::normalize($summaryCounts),
])->save();
}
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;
}
/**
* @param list<int> $evidenceIds
* @param array<string, int> $summaryCounts
* @return array{
* canonical_type: string,
* outcome: string,
* adapter_outcome: string,
* item_count: int,
* reason_code?: string|null,
* source_contract_key?: string|null,
* evidence_ids: list<int>,
* summary_counts: array<string, int>
* }
*/
private function result(
string $canonicalType,
CaptureOutcome $outcome,
string $adapterOutcome,
int $itemCount,
?string $reasonCode,
?string $sourceContractKey,
array $evidenceIds,
array $summaryCounts,
): array {
return [
'canonical_type' => $canonicalType,
'outcome' => $outcome->value,
'adapter_outcome' => $adapterOutcome,
'item_count' => max(0, $itemCount),
'reason_code' => $reasonCode,
'source_contract_key' => $sourceContractKey,
'evidence_ids' => $evidenceIds,
'summary_counts' => $summaryCounts,
];
}
}