TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php
ahmido 30a6733f78 Spec 436: Exchange content-backed evidence promotion (#503)
Summary: Wire Exchange PowerShell evidence capture through Spec 435 structured envelopes, target normalizers, and hash builder for transportRule, remoteDomain, and inboundConnector; preserve append-only internal content-backed evidence with fail-closed readiness, output, identity, and redaction behavior; add Spec 436 artifacts and focused coverage. Validation: php artisan test --filter=Spec436 --compact PASS, 28 tests / 307 assertions; git diff --cached --check PASS before commit. Product/Ops: Livewire v4 unchanged; provider registration unchanged under apps/platform/bootstrap/providers.php; global search unchanged; no destructive/high-impact actions; no assets/browser/UI/deployment impact.
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #503
2026-07-09 14:03:10 +00:00

444 lines
17 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 ExchangePowerShellCommandContracts $commandContracts,
private readonly ExchangePowerShellEvidenceNormalizer $normalizer,
private readonly ExchangePowerShellHashInputBuilder $hashes,
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'];
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: $canonicalType,
contract: ExchangePowerShellCommandContract::fromVerifiedArray($contract, $this->commandContracts),
runnerMode: $this->runnerMode($operationRun, $runnerResult),
result: $runnerResult,
);
$readiness = $this->normalizer->evaluate($envelope);
if (! $readiness->ready) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
adapterOutcome: $this->normalizerBlockedAdapterOutcome($readiness),
itemCount: 0,
reasonCode: $readiness->blockers[0] ?? 'exchange_normalizer_blocked',
sourceContractKey: $sourceDecision->contractKey,
evidenceIds: [],
summaryCounts: $summaryCounts,
);
}
if ($readiness->emptyCollection) {
$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);
$evidenceItems = $this->normalizer->normalizedEvidenceItems($envelope);
if ($evidenceItems === [] || count($evidenceItems) !== $readiness->itemCount) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
adapterOutcome: self::OUTCOME_PREREQUISITE_BLOCKED,
itemCount: 0,
reasonCode: 'exchange_normalizer_hash_unready',
sourceContractKey: $sourceDecision->contractKey,
evidenceIds: [],
summaryCounts: $summaryCounts,
);
}
$identity = $this->identityGate->evaluateCollection(
tenant: $tenant,
providerConnection: $providerConnection,
resourceType: $resourceType,
items: array_values(array_map(
static fn (array $item): array => $item['raw_payload'],
$evidenceItems,
)),
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 = [];
$permissionContext = $this->permissionContext($providerConnection);
foreach ($evidenceItems as $item) {
$rawPayload = $item['raw_payload'];
$normalizedPayload = $this->evidenceNormalizedPayload(
normalizedPayload: $item['normalized_payload'],
decision: $decision,
envelope: $envelope,
readiness: $readiness,
);
$payloadHash = $this->evidencePayloadHash($normalizedPayload, $envelope, $readiness);
$resource = $this->resourceUpserter->upsert(
tenant: $tenant,
providerConnection: $providerConnection,
resourceType: $resourceType,
payload: $rawPayload,
sourceMetadata: $decision->sourceMetadata,
);
$evidence = $this->evidenceWriter->append(
resource: $resource,
resourceType: $resourceType,
providerConnection: $providerConnection,
operationRun: $operationRun,
decision: $decision,
rawPayload: $rawPayload,
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> $normalizedPayload
* @return array<string, mixed>
*/
private function evidenceNormalizedPayload(
array $normalizedPayload,
CoverageSourceContractDecision $decision,
ExchangePowerShellStructuredOutputEnvelope $envelope,
ExchangePowerShellNormalizerReadiness $readiness,
): array {
$source = array_filter([
'source_contract_key' => $decision->contractKey,
'source_endpoint' => $decision->sourceEndpoint,
'source_version' => $decision->sourceVersion,
'source_schema_hash' => $decision->sourceSchemaHash,
'source_surface' => $envelope->sourceSurface,
'command_contract_name' => $envelope->commandContractName,
'command_contract_version' => $envelope->commandContractVersion,
'payload_shape_version' => $readiness->definition['payload_shape_version'] ?? null,
'normalizer_version' => $readiness->definition['normalizer_version'] ?? null,
], static fn (mixed $value): bool => $value !== null && $value !== '');
return [
...$normalizedPayload,
'source' => $source,
];
}
/**
* @param array<string, mixed> $normalizedPayload
*/
private function evidencePayloadHash(
array $normalizedPayload,
ExchangePowerShellStructuredOutputEnvelope $envelope,
ExchangePowerShellNormalizerReadiness $readiness,
): string {
$hashInput = $this->hashes->build(
resourceType: $envelope->resourceType,
sourceSurface: $envelope->sourceSurface,
commandContractName: $envelope->commandContractName,
commandContractVersion: $envelope->commandContractVersion,
payloadShapeVersion: (string) ($readiness->definition['payload_shape_version'] ?? ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION),
normalizerVersion: (string) ($readiness->definition['normalizer_version'] ?? 'unknown'),
normalizedPayload: $normalizedPayload,
);
return $this->hashes->hash($hashInput);
}
/**
* @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 : [];
}
private function runnerMode(OperationRun $operationRun, ExchangePowerShellInvocationResult $runnerResult): string
{
$runnerMode = $runnerResult->context['runner_mode'] ?? data_get($operationRun->context, 'runner_mode');
return is_string($runnerMode) && trim($runnerMode) !== '' ? trim($runnerMode) : 'unknown';
}
private function normalizerBlockedAdapterOutcome(ExchangePowerShellNormalizerReadiness $readiness): string
{
foreach ($readiness->blockers as $blocker) {
if (str_contains($blocker, 'identity')
|| str_contains($blocker, 'display_name')
|| str_contains($blocker, 'duplicate')
|| str_contains($blocker, 'missing_stable')
|| str_contains($blocker, 'derived')
) {
return self::OUTCOME_IDENTITY_BLOCKED;
}
}
return self::OUTCOME_PREREQUISITE_BLOCKED;
}
/**
* @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,
];
}
}