178 lines
7.6 KiB
PHP
178 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Operations\Reconciliation;
|
|
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\OperationRun;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\Operations\LifecycleReconciliationReason;
|
|
use Throwable;
|
|
|
|
final class EvidenceSnapshotReconciliationAdapter implements OperationRunReconciliationAdapter
|
|
{
|
|
public function key(): string
|
|
{
|
|
return 'evidence_snapshot';
|
|
}
|
|
|
|
public function supportedTypes(): array
|
|
{
|
|
return ['tenant.evidence.snapshot.generate'];
|
|
}
|
|
|
|
public function supportsType(string $type): bool
|
|
{
|
|
return $type === 'tenant.evidence.snapshot.generate';
|
|
}
|
|
|
|
public function reconcile(OperationRun $run): ?ReconciliationResult
|
|
{
|
|
if (! $this->supportsType((string) $run->type)) {
|
|
return ReconciliationResult::unsupported(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'This adapter only supports evidence snapshot generation runs.',
|
|
evidence: [
|
|
'adapter' => $this->key(),
|
|
'type' => (string) $run->type,
|
|
],
|
|
);
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$workspaceId = (int) ($run->workspace_id ?? $context['workspace_id'] ?? 0);
|
|
$tenantId = (int) ($run->managed_environment_id ?? $context['managed_environment_id'] ?? 0);
|
|
$fingerprint = is_string($context['fingerprint'] ?? null)
|
|
? trim((string) $context['fingerprint'])
|
|
: '';
|
|
|
|
$evidence = [
|
|
'adapter' => $this->key(),
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'workspace_id' => $workspaceId,
|
|
'managed_environment_id' => $tenantId,
|
|
'fingerprint' => $fingerprint,
|
|
];
|
|
|
|
if ($workspaceId <= 0 || $tenantId <= 0 || $fingerprint === '') {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The run no longer carries enough evidence snapshot scope to reconcile it safely.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$snapshot = EvidenceSnapshot::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->where('managed_environment_id', $tenantId)
|
|
->where('fingerprint', $fingerprint)
|
|
->latest('id')
|
|
->first();
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'No matching evidence snapshot is available yet for this run.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$related = $this->relatedSnapshotMetadata($snapshot);
|
|
$status = (string) $snapshot->status;
|
|
$completeness = (string) $snapshot->completeness_state;
|
|
|
|
if ($this->isUsableSnapshot($snapshot)) {
|
|
return ReconciliationResult::reconciledSucceeded(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: (int) ($snapshot->operation_run_id ?? 0) === (int) $run->getKey()
|
|
? 'The queued evidence snapshot was already completed before the run finished updating.'
|
|
: 'A matching evidence snapshot was already available for this run.',
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
summaryCounts: $this->summaryCounts($snapshot),
|
|
);
|
|
}
|
|
|
|
if (in_array($status, [
|
|
EvidenceSnapshotStatus::Queued->value,
|
|
EvidenceSnapshotStatus::Generating->value,
|
|
], true)) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: (int) ($snapshot->operation_run_id ?? 0) === (int) $run->getKey()
|
|
? 'The queued evidence snapshot is still generating and does not prove final snapshot truth yet.'
|
|
: 'A matching evidence snapshot exists, but it is still generating.',
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
);
|
|
}
|
|
|
|
$reasonMessage = match (true) {
|
|
$status === EvidenceSnapshotStatus::Expired->value => 'A matching evidence snapshot exists, but it is already expired.',
|
|
$status === EvidenceSnapshotStatus::Superseded->value => 'A matching evidence snapshot exists, but it was superseded by newer evidence.',
|
|
$status === EvidenceSnapshotStatus::Failed->value => 'A matching evidence snapshot exists, but evidence generation failed.',
|
|
$completeness === EvidenceCompletenessState::Stale->value => 'A matching evidence snapshot exists, but its evidence basis is already stale.',
|
|
$completeness === EvidenceCompletenessState::Partial->value => 'A matching evidence snapshot exists, but its evidence basis is incomplete.',
|
|
default => 'A matching evidence snapshot exists, but it does not provide usable evidence truth yet.',
|
|
};
|
|
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: $reasonMessage,
|
|
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
|
|
related: $related,
|
|
summaryCounts: $this->summaryCounts($snapshot),
|
|
);
|
|
}
|
|
|
|
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
|
|
{
|
|
return null;
|
|
}
|
|
|
|
private function isUsableSnapshot(EvidenceSnapshot $snapshot): bool
|
|
{
|
|
if ((string) $snapshot->status !== EvidenceSnapshotStatus::Active->value) {
|
|
return false;
|
|
}
|
|
|
|
if ((string) $snapshot->completeness_state !== EvidenceCompletenessState::Complete->value) {
|
|
return false;
|
|
}
|
|
|
|
return $snapshot->expires_at === null || $snapshot->expires_at->isFuture();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function relatedSnapshotMetadata(EvidenceSnapshot $snapshot): array
|
|
{
|
|
return array_filter([
|
|
'type' => 'evidence_snapshot',
|
|
'id' => (int) $snapshot->getKey(),
|
|
'status' => (string) $snapshot->status,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'fingerprint' => (string) $snapshot->fingerprint,
|
|
'operation_run_id' => is_numeric($snapshot->operation_run_id) ? (int) $snapshot->operation_run_id : null,
|
|
'expires_at' => $snapshot->expires_at?->toIso8601String(),
|
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function summaryCounts(EvidenceSnapshot $snapshot): array
|
|
{
|
|
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
|
|
|
return array_filter([
|
|
'finding_count' => is_numeric($summary['finding_count'] ?? null) ? (int) $summary['finding_count'] : null,
|
|
'report_count' => is_numeric($summary['report_count'] ?? null) ? (int) $summary['report_count'] : null,
|
|
'operation_count' => is_numeric($summary['operation_count'] ?? null) ? (int) $summary['operation_count'] : null,
|
|
], static fn (mixed $value): bool => is_int($value));
|
|
}
|
|
}
|