424 lines
17 KiB
PHP
424 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Evidence;
|
|
|
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
final class EvidenceAnchorResolver
|
|
{
|
|
public function currentForScope(Workspace $workspace, ?ManagedEnvironment $environment = null, ?User $actor = null): EvidenceAnchorResult
|
|
{
|
|
if (! $environment instanceof ManagedEnvironment) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_UNKNOWN,
|
|
primaryReason: 'Select an environment to open current evidence.',
|
|
blockingReasons: ['Workspace-wide evidence cannot be represented by one arbitrary environment snapshot.'],
|
|
displayLabel: 'Current evidence',
|
|
);
|
|
}
|
|
|
|
if ((int) $environment->workspace_id !== (int) $workspace->getKey()) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_BLOCKED,
|
|
primaryReason: 'The environment does not belong to the selected workspace.',
|
|
blockingReasons: ['Evidence scope mismatch.'],
|
|
displayLabel: 'Current evidence',
|
|
);
|
|
}
|
|
|
|
if ($actor instanceof User && ! $actor->canAccessTenant($environment)) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_BLOCKED,
|
|
primaryReason: 'You do not have access to this environment.',
|
|
blockingReasons: ['Environment access is required before evidence can be linked.'],
|
|
displayLabel: 'Current evidence',
|
|
);
|
|
}
|
|
|
|
$snapshot = $this->currentSnapshotForEnvironment($environment);
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
$hasAnySnapshot = EvidenceSnapshot::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('managed_environment_id', (int) $environment->getKey())
|
|
->exists();
|
|
|
|
return $this->noValid(
|
|
state: $hasAnySnapshot
|
|
? EvidenceAnchorResult::STATE_NEEDS_ATTENTION
|
|
: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
|
|
primaryReason: $hasAnySnapshot
|
|
? 'No complete active evidence snapshot is available for this environment.'
|
|
: 'No evidence snapshot is configured for this environment.',
|
|
blockingReasons: $hasAnySnapshot
|
|
? ['Only active, complete, non-expired evidence can be used as current evidence.']
|
|
: ['Generate evidence before opening a current evidence detail.'],
|
|
displayLabel: 'Current evidence',
|
|
);
|
|
}
|
|
|
|
return $this->fromSnapshot(
|
|
snapshot: $snapshot,
|
|
anchorType: EvidenceAnchorResult::TYPE_CURRENT_SCOPE_EVIDENCE,
|
|
actor: $actor,
|
|
displayLabel: 'Current evidence',
|
|
primaryReason: 'Current complete evidence is available for this environment.',
|
|
isCurrent: true,
|
|
);
|
|
}
|
|
|
|
public function currentSnapshotForEnvironment(ManagedEnvironment $environment): ?EvidenceSnapshot
|
|
{
|
|
$workspace = $environment->workspace;
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return null;
|
|
}
|
|
|
|
return $this->currentSnapshotQuery($workspace, $environment)
|
|
->with([
|
|
'tenant',
|
|
'operationRun',
|
|
'reviewPacks.operationRun',
|
|
'reviewPacks.environmentReview.currentExportReviewPack',
|
|
'items',
|
|
])
|
|
->first();
|
|
}
|
|
|
|
public function currentSnapshotQuery(Workspace $workspace, ManagedEnvironment $environment): Builder
|
|
{
|
|
return EvidenceSnapshot::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('managed_environment_id', (int) $environment->getKey())
|
|
->where('status', EvidenceSnapshotStatus::Active->value)
|
|
->where('completeness_state', EvidenceCompletenessState::Complete->value)
|
|
->where(function (Builder $query): void {
|
|
$query
|
|
->whereNull('expires_at')
|
|
->orWhere('expires_at', '>', now());
|
|
})
|
|
->orderByRaw('generated_at IS NULL')
|
|
->orderByDesc('generated_at')
|
|
->orderByDesc('id');
|
|
}
|
|
|
|
public function forReviewPackDraft(ReviewPack $reviewPack, ?User $actor = null): EvidenceAnchorResult
|
|
{
|
|
$reviewPack->loadMissing(['tenant', 'evidenceSnapshot']);
|
|
|
|
if (! $reviewPack->evidenceSnapshot instanceof EvidenceSnapshot) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
|
|
primaryReason: 'This draft review pack has no bound evidence snapshot.',
|
|
blockingReasons: ['Draft review evidence is missing.'],
|
|
displayLabel: 'Draft review evidence',
|
|
);
|
|
}
|
|
|
|
return $this->fromSnapshot(
|
|
snapshot: $reviewPack->evidenceSnapshot,
|
|
anchorType: EvidenceAnchorResult::TYPE_REVIEW_DRAFT_EVIDENCE,
|
|
actor: $actor,
|
|
displayLabel: 'Draft review evidence',
|
|
primaryReason: 'Draft review evidence is bound to this pack.',
|
|
isTechnicalOnly: true,
|
|
);
|
|
}
|
|
|
|
public function forReviewPackRelease(ReviewPack $reviewPack, ?User $actor = null): EvidenceAnchorResult
|
|
{
|
|
$reviewPack->loadMissing(['tenant', 'evidenceSnapshot', 'environmentReview.evidenceSnapshot']);
|
|
|
|
$snapshot = $reviewPack->evidenceSnapshot
|
|
?? $reviewPack->environmentReview?->evidenceSnapshot;
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
|
|
primaryReason: 'This review pack has no release-bound evidence snapshot.',
|
|
blockingReasons: ['Release-bound evidence is missing.'],
|
|
displayLabel: 'Review evidence',
|
|
);
|
|
}
|
|
|
|
if (! $this->snapshotMatchesArtifactScope($snapshot, $reviewPack)) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_BLOCKED,
|
|
primaryReason: 'The bound evidence snapshot does not match the review pack scope.',
|
|
blockingReasons: ['Release-bound evidence scope mismatch.'],
|
|
displayLabel: 'Review evidence',
|
|
);
|
|
}
|
|
|
|
return $this->fromSnapshot(
|
|
snapshot: $snapshot,
|
|
anchorType: EvidenceAnchorResult::TYPE_REVIEW_RELEASED_EVIDENCE,
|
|
actor: $actor,
|
|
displayLabel: 'Review evidence',
|
|
primaryReason: 'Evidence captured for this released review is available.',
|
|
isReleaseBound: true,
|
|
);
|
|
}
|
|
|
|
public function forEnvironmentReviewRelease(EnvironmentReview $review, ?User $actor = null): EvidenceAnchorResult
|
|
{
|
|
$review->loadMissing(['tenant', 'evidenceSnapshot']);
|
|
|
|
if (! $review->evidenceSnapshot instanceof EvidenceSnapshot) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
|
|
primaryReason: 'This review has no bound evidence snapshot.',
|
|
blockingReasons: ['Review evidence is missing.'],
|
|
displayLabel: 'Review evidence',
|
|
);
|
|
}
|
|
|
|
if (! $this->snapshotMatchesArtifactScope($review->evidenceSnapshot, $review)) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_BLOCKED,
|
|
primaryReason: 'The bound evidence snapshot does not match the review scope.',
|
|
blockingReasons: ['Review evidence scope mismatch.'],
|
|
displayLabel: 'Review evidence',
|
|
);
|
|
}
|
|
|
|
return $this->fromSnapshot(
|
|
snapshot: $review->evidenceSnapshot,
|
|
anchorType: EvidenceAnchorResult::TYPE_REVIEW_RELEASED_EVIDENCE,
|
|
actor: $actor,
|
|
displayLabel: 'Review evidence',
|
|
primaryReason: 'Evidence captured for this review is available.',
|
|
isReleaseBound: true,
|
|
);
|
|
}
|
|
|
|
public function forCustomerWorkspace(EnvironmentReview|ReviewPack $reviewPackOrReview, ?User $actor = null): EvidenceAnchorResult
|
|
{
|
|
$snapshot = match (true) {
|
|
$reviewPackOrReview instanceof EnvironmentReview => tap($reviewPackOrReview)->loadMissing('evidenceSnapshot')->evidenceSnapshot,
|
|
$reviewPackOrReview instanceof ReviewPack => tap($reviewPackOrReview)->loadMissing(['evidenceSnapshot', 'environmentReview.evidenceSnapshot'])->evidenceSnapshot
|
|
?? $reviewPackOrReview->environmentReview?->evidenceSnapshot,
|
|
};
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return $this->noValid(
|
|
anchorType: EvidenceAnchorResult::TYPE_CUSTOMER_SAFE_EVIDENCE_SUMMARY,
|
|
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
|
|
primaryReason: 'No review evidence summary is available for this customer workspace item.',
|
|
blockingReasons: ['Released review evidence is missing.'],
|
|
displayLabel: 'Review evidence',
|
|
isCustomerSafe: true,
|
|
);
|
|
}
|
|
|
|
if (! $this->snapshotMatchesArtifactScope($snapshot, $reviewPackOrReview)) {
|
|
return $this->noValid(
|
|
state: EvidenceAnchorResult::STATE_BLOCKED,
|
|
primaryReason: 'Review evidence cannot be summarized because its scope does not match this customer workspace item.',
|
|
blockingReasons: ['Review evidence scope mismatch.'],
|
|
displayLabel: 'Review evidence',
|
|
);
|
|
}
|
|
|
|
$state = $this->stateForSnapshot($snapshot, releaseBound: true);
|
|
$isExpired = $this->isExpired($snapshot);
|
|
$isPartial = $this->isPartial($snapshot);
|
|
|
|
return new EvidenceAnchorResult(
|
|
anchorType: EvidenceAnchorResult::TYPE_CUSTOMER_SAFE_EVIDENCE_SUMMARY,
|
|
state: $state,
|
|
evidenceSnapshotId: null,
|
|
targetRoute: null,
|
|
isCurrent: false,
|
|
isReleaseBound: true,
|
|
isCustomerSafe: true,
|
|
isTechnicalOnly: false,
|
|
isPartial: $isPartial,
|
|
isSuperseded: (string) $snapshot->status === EvidenceSnapshotStatus::Superseded->value,
|
|
isExpired: $isExpired,
|
|
canLink: false,
|
|
canViewTechnicalDetail: false,
|
|
primaryReason: $isExpired
|
|
? 'The evidence captured for this review is expired.'
|
|
: 'Evidence captured for this review is summarized for customer review.',
|
|
blockingReasons: $state === EvidenceAnchorResult::STATE_READY ? [] : [$this->blockingReasonForSnapshot($snapshot, true)],
|
|
displayLabel: 'Evidence captured for this review',
|
|
);
|
|
}
|
|
|
|
public function technicalDetail(EvidenceSnapshot $snapshot, ?User $actor = null): EvidenceAnchorResult
|
|
{
|
|
$snapshot->loadMissing('tenant');
|
|
$targetRoute = $this->targetRouteFor($snapshot, $actor);
|
|
|
|
return $this->fromSnapshot(
|
|
snapshot: $snapshot,
|
|
anchorType: EvidenceAnchorResult::TYPE_TECHNICAL_EVIDENCE_DETAIL,
|
|
actor: $actor,
|
|
displayLabel: 'View internal evidence details',
|
|
primaryReason: $targetRoute !== null
|
|
? 'Internal evidence details are available for authorized operators.'
|
|
: 'Internal evidence details require evidence view permission.',
|
|
isTechnicalOnly: true,
|
|
targetRoute: $targetRoute,
|
|
);
|
|
}
|
|
|
|
private function fromSnapshot(
|
|
EvidenceSnapshot $snapshot,
|
|
string $anchorType,
|
|
?User $actor,
|
|
string $displayLabel,
|
|
string $primaryReason,
|
|
bool $isCurrent = false,
|
|
bool $isReleaseBound = false,
|
|
bool $isCustomerSafe = false,
|
|
bool $isTechnicalOnly = false,
|
|
?string $targetRoute = null,
|
|
): EvidenceAnchorResult {
|
|
$snapshot->loadMissing('tenant');
|
|
$targetRoute ??= $this->targetRouteFor($snapshot, $actor);
|
|
$state = $this->stateForSnapshot($snapshot, $isReleaseBound);
|
|
$canViewTechnicalDetail = $this->targetRouteFor($snapshot, $actor) !== null;
|
|
|
|
return new EvidenceAnchorResult(
|
|
anchorType: $anchorType,
|
|
state: $state,
|
|
evidenceSnapshotId: (int) $snapshot->getKey(),
|
|
targetRoute: $targetRoute,
|
|
isCurrent: $isCurrent,
|
|
isReleaseBound: $isReleaseBound,
|
|
isCustomerSafe: $isCustomerSafe,
|
|
isTechnicalOnly: $isTechnicalOnly,
|
|
isPartial: $this->isPartial($snapshot),
|
|
isSuperseded: (string) $snapshot->status === EvidenceSnapshotStatus::Superseded->value,
|
|
isExpired: $this->isExpired($snapshot),
|
|
canLink: $targetRoute !== null,
|
|
canViewTechnicalDetail: $canViewTechnicalDetail,
|
|
primaryReason: $primaryReason,
|
|
blockingReasons: $state === EvidenceAnchorResult::STATE_READY
|
|
? []
|
|
: [$this->blockingReasonForSnapshot($snapshot, $isReleaseBound)],
|
|
displayLabel: $displayLabel,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $blockingReasons
|
|
*/
|
|
private function noValid(
|
|
string $state,
|
|
string $primaryReason,
|
|
array $blockingReasons,
|
|
string $displayLabel,
|
|
string $anchorType = EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE,
|
|
bool $isCustomerSafe = false,
|
|
): EvidenceAnchorResult {
|
|
return new EvidenceAnchorResult(
|
|
anchorType: $anchorType,
|
|
state: $state,
|
|
evidenceSnapshotId: null,
|
|
targetRoute: null,
|
|
isCurrent: false,
|
|
isReleaseBound: false,
|
|
isCustomerSafe: $isCustomerSafe,
|
|
isTechnicalOnly: false,
|
|
isPartial: false,
|
|
isSuperseded: false,
|
|
isExpired: false,
|
|
canLink: false,
|
|
canViewTechnicalDetail: false,
|
|
primaryReason: $primaryReason,
|
|
blockingReasons: $blockingReasons,
|
|
displayLabel: $displayLabel,
|
|
);
|
|
}
|
|
|
|
private function targetRouteFor(EvidenceSnapshot $snapshot, ?User $actor): ?string
|
|
{
|
|
$tenant = $snapshot->tenant;
|
|
|
|
if (! $actor instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
if (! $actor->canAccessTenant($tenant) || ! $actor->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
|
return null;
|
|
}
|
|
|
|
return EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin');
|
|
}
|
|
|
|
private function stateForSnapshot(EvidenceSnapshot $snapshot, bool $releaseBound): string
|
|
{
|
|
if ($this->isExpired($snapshot)) {
|
|
return EvidenceAnchorResult::STATE_EXPIRED;
|
|
}
|
|
|
|
if ((string) $snapshot->status === EvidenceSnapshotStatus::Failed->value) {
|
|
return EvidenceAnchorResult::STATE_BLOCKED;
|
|
}
|
|
|
|
if ($this->isPartial($snapshot)) {
|
|
return EvidenceAnchorResult::STATE_NEEDS_ATTENTION;
|
|
}
|
|
|
|
if (! $releaseBound && (string) $snapshot->status !== EvidenceSnapshotStatus::Active->value) {
|
|
return EvidenceAnchorResult::STATE_BLOCKED;
|
|
}
|
|
|
|
return EvidenceAnchorResult::STATE_READY;
|
|
}
|
|
|
|
private function blockingReasonForSnapshot(EvidenceSnapshot $snapshot, bool $releaseBound): string
|
|
{
|
|
if ($this->isExpired($snapshot)) {
|
|
return 'Evidence has expired.';
|
|
}
|
|
|
|
if ((string) $snapshot->status === EvidenceSnapshotStatus::Failed->value) {
|
|
return 'Evidence generation failed.';
|
|
}
|
|
|
|
if ($this->isPartial($snapshot)) {
|
|
return 'Evidence completeness requires attention.';
|
|
}
|
|
|
|
if (! $releaseBound && (string) $snapshot->status !== EvidenceSnapshotStatus::Active->value) {
|
|
return 'Evidence is not an active current snapshot.';
|
|
}
|
|
|
|
return 'Evidence cannot be linked from this surface.';
|
|
}
|
|
|
|
private function isPartial(EvidenceSnapshot $snapshot): bool
|
|
{
|
|
return (string) $snapshot->completeness_state !== EvidenceCompletenessState::Complete->value;
|
|
}
|
|
|
|
private function isExpired(EvidenceSnapshot $snapshot): bool
|
|
{
|
|
return (string) $snapshot->status === EvidenceSnapshotStatus::Expired->value
|
|
|| ($snapshot->expires_at !== null && $snapshot->expires_at->isPast());
|
|
}
|
|
|
|
private function snapshotMatchesArtifactScope(EvidenceSnapshot $snapshot, EnvironmentReview|ReviewPack $artifact): bool
|
|
{
|
|
return (int) $snapshot->workspace_id === (int) $artifact->workspace_id
|
|
&& (int) $snapshot->managed_environment_id === (int) $artifact->managed_environment_id;
|
|
}
|
|
}
|