Implemented report evidence reconciliation. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #432
315 lines
13 KiB
PHP
315 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Operations\Reconciliation;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Operations\LifecycleReconciliationReason;
|
|
use App\Support\ReviewPackStatus;
|
|
use Throwable;
|
|
|
|
final class ReviewPackArtifactReconciliationAdapter implements OperationRunReconciliationAdapter
|
|
{
|
|
public function __construct(
|
|
private readonly ReviewPackService $reviewPacks,
|
|
) {}
|
|
|
|
public function key(): string
|
|
{
|
|
return 'review_pack';
|
|
}
|
|
|
|
public function supportedTypes(): array
|
|
{
|
|
return ['environment.review_pack.generate'];
|
|
}
|
|
|
|
public function supportsType(string $type): bool
|
|
{
|
|
return $type === 'environment.review_pack.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 review pack 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);
|
|
$reviewId = is_numeric($context['environment_review_id'] ?? null) ? (int) $context['environment_review_id'] : null;
|
|
$snapshotId = is_numeric($context['evidence_snapshot_id'] ?? null) ? (int) $context['evidence_snapshot_id'] : null;
|
|
$options = $this->normalizeOptions($context);
|
|
|
|
$evidence = [
|
|
'adapter' => $this->key(),
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'workspace_id' => $workspaceId,
|
|
'managed_environment_id' => $tenantId,
|
|
'environment_review_id' => $reviewId,
|
|
'evidence_snapshot_id' => $snapshotId,
|
|
'include_pii' => $options['include_pii'],
|
|
'include_operations' => $options['include_operations'],
|
|
];
|
|
|
|
if ($workspaceId <= 0 || $tenantId <= 0 || ($reviewId === null && $snapshotId === null)) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The run no longer carries enough review pack scope to reconcile it safely.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$tenant = ManagedEnvironment::query()
|
|
->whereKey($tenantId)
|
|
->where('workspace_id', $workspaceId)
|
|
->first();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The run no longer points at a valid workspace-owned environment for review pack reconciliation.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$expectedFingerprint = null;
|
|
$candidatesQuery = ReviewPack::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->where('managed_environment_id', $tenantId)
|
|
->orderByDesc('generated_at')
|
|
->orderByDesc('id');
|
|
|
|
if ($reviewId !== null) {
|
|
$review = EnvironmentReview::query()
|
|
->whereKey($reviewId)
|
|
->where('workspace_id', $workspaceId)
|
|
->where('managed_environment_id', $tenantId)
|
|
->first();
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The queued review scope no longer resolves to a current review record safely.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$expectedFingerprint = $this->reviewPacks->computeFingerprintForReview($review, $options);
|
|
$candidatesQuery->where('environment_review_id', $reviewId);
|
|
} else {
|
|
$snapshot = EvidenceSnapshot::query()
|
|
->whereKey($snapshotId)
|
|
->where('workspace_id', $workspaceId)
|
|
->where('managed_environment_id', $tenantId)
|
|
->first();
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The queued evidence basis for this review pack no longer resolves safely.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$expectedFingerprint = $this->reviewPacks->computeFingerprintForSnapshot($snapshot, $options);
|
|
$candidatesQuery->where('evidence_snapshot_id', (int) $snapshot->getKey());
|
|
}
|
|
|
|
$evidence['expected_fingerprint'] = $expectedFingerprint;
|
|
|
|
$candidates = $candidatesQuery
|
|
->get()
|
|
->filter(fn (ReviewPack $pack): bool => $this->matchesOptions($pack, $options))
|
|
->values();
|
|
|
|
$evidence['considered_pack_ids'] = $candidates->modelKeys();
|
|
$evidence['considered_packs'] = $candidates
|
|
->map(fn (ReviewPack $pack): array => $this->reviewPackReference($pack))
|
|
->values()
|
|
->all();
|
|
|
|
/** @var ReviewPack|null $pack */
|
|
$pack = $candidates->first(fn (ReviewPack $candidate): bool => (string) $candidate->fingerprint === $expectedFingerprint);
|
|
|
|
if (! $pack instanceof ReviewPack) {
|
|
if ($candidates->isEmpty()) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'No matching review pack is available yet for this run.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
if ($candidates->count() === 1) {
|
|
$candidate = $candidates->first();
|
|
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'A review pack exists for this scope, but its fingerprint no longer matches the queued run safely.',
|
|
evidence: $evidence + ['chosen_review_pack_id' => (int) $candidate->getKey()],
|
|
related: $this->relatedReviewPackMetadata($candidate),
|
|
summaryCounts: $this->summaryCounts($candidate),
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'Multiple review packs match this scope, so the run needs manual review.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$related = $this->relatedReviewPackMetadata($pack);
|
|
|
|
if ($this->isUsablePack($pack)) {
|
|
return ReconciliationResult::reconciledSucceeded(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: (int) ($pack->operation_run_id ?? 0) === (int) $run->getKey()
|
|
? 'The queued review pack was already completed before the run finished updating.'
|
|
: 'A matching review pack was already available for this run.',
|
|
evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()],
|
|
related: $related,
|
|
summaryCounts: $this->summaryCounts($pack),
|
|
);
|
|
}
|
|
|
|
if (in_array((string) $pack->status, [
|
|
ReviewPackStatus::Queued->value,
|
|
ReviewPackStatus::Generating->value,
|
|
], true)) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: (int) ($pack->operation_run_id ?? 0) === (int) $run->getKey()
|
|
? 'The queued review pack is still generating and does not prove final output truth yet.'
|
|
: 'A matching review pack exists, but it is still generating.',
|
|
evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()],
|
|
related: $related,
|
|
);
|
|
}
|
|
|
|
$reasonMessage = match (true) {
|
|
(string) $pack->status === ReviewPackStatus::Expired->value => 'A matching review pack exists, but it is already expired.',
|
|
(string) $pack->status === ReviewPackStatus::Failed->value => 'A matching review pack exists, but generation failed.',
|
|
! $this->hasShareableFile($pack) => 'A matching review pack exists, but it does not expose a shareable artifact safely yet.',
|
|
default => 'A matching review pack exists, but it is not ready for use.',
|
|
};
|
|
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: $reasonMessage,
|
|
evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()],
|
|
related: $related,
|
|
summaryCounts: $this->summaryCounts($pack),
|
|
);
|
|
}
|
|
|
|
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
|
|
{
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array{include_pii: bool, include_operations: bool}
|
|
*/
|
|
private function normalizeOptions(array $context): array
|
|
{
|
|
return [
|
|
'include_pii' => (bool) ($context['include_pii'] ?? true),
|
|
'include_operations' => (bool) ($context['include_operations'] ?? true),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{include_pii: bool, include_operations: bool} $options
|
|
*/
|
|
private function matchesOptions(ReviewPack $pack, array $options): bool
|
|
{
|
|
$packOptions = is_array($pack->options) ? $pack->options : [];
|
|
|
|
return (bool) ($packOptions['include_pii'] ?? true) === $options['include_pii']
|
|
&& (bool) ($packOptions['include_operations'] ?? true) === $options['include_operations'];
|
|
}
|
|
|
|
private function isUsablePack(ReviewPack $pack): bool
|
|
{
|
|
if ((string) $pack->status !== ReviewPackStatus::Ready->value) {
|
|
return false;
|
|
}
|
|
|
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
|
return false;
|
|
}
|
|
|
|
return $this->hasShareableFile($pack);
|
|
}
|
|
|
|
private function hasShareableFile(ReviewPack $pack): bool
|
|
{
|
|
return is_string($pack->file_disk) && trim($pack->file_disk) !== ''
|
|
&& is_string($pack->file_path) && trim($pack->file_path) !== ''
|
|
&& is_string($pack->sha256) && trim($pack->sha256) !== '';
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function relatedReviewPackMetadata(ReviewPack $pack): array
|
|
{
|
|
return array_filter([
|
|
'type' => 'review_pack',
|
|
'id' => (int) $pack->getKey(),
|
|
'status' => (string) $pack->status,
|
|
'fingerprint' => (string) $pack->fingerprint,
|
|
'operation_run_id' => is_numeric($pack->operation_run_id) ? (int) $pack->operation_run_id : null,
|
|
'environment_review_id' => is_numeric($pack->environment_review_id) ? (int) $pack->environment_review_id : null,
|
|
'evidence_snapshot_id' => is_numeric($pack->evidence_snapshot_id) ? (int) $pack->evidence_snapshot_id : null,
|
|
'expires_at' => $pack->expires_at?->toIso8601String(),
|
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewPackReference(ReviewPack $pack): array
|
|
{
|
|
return [
|
|
'id' => (int) $pack->getKey(),
|
|
'status' => (string) $pack->status,
|
|
'fingerprint' => (string) $pack->fingerprint,
|
|
'operation_run_id' => is_numeric($pack->operation_run_id) ? (int) $pack->operation_run_id : null,
|
|
'environment_review_id' => is_numeric($pack->environment_review_id) ? (int) $pack->environment_review_id : null,
|
|
'evidence_snapshot_id' => is_numeric($pack->evidence_snapshot_id) ? (int) $pack->evidence_snapshot_id : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function summaryCounts(ReviewPack $pack): array
|
|
{
|
|
$summary = is_array($pack->summary) ? $pack->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));
|
|
}
|
|
}
|