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
382 lines
14 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|