canAccessTenant($tenant)) { throw new NotFoundHttpException; } Gate::forUser($actor)->authorize(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $tenant); if ((int) $run->tenant_id !== (int) $tenant->getKey()) { throw new NotFoundHttpException; } if ((int) $run->workspace_id !== (int) $tenant->workspace_id) { throw new NotFoundHttpException; } $checkKey = trim($checkKey); if ($checkKey === '') { throw new InvalidArgumentException('check_key is required.'); } $ackReason = trim($ackReason); if ($ackReason === '') { throw new InvalidArgumentException('ack_reason is required.'); } if (mb_strlen($ackReason) > 160) { throw new InvalidArgumentException('ack_reason must be at most 160 characters.'); } $report = $this->reportForRun($run); $check = $this->findCheckByKey($report, $checkKey); $status = $check['status'] ?? null; if (! is_string($status) || ! in_array($status, [VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value], true)) { throw new InvalidArgumentException('Only failing or warning checks can be acknowledged.'); } $reasonCode = $check['reason_code'] ?? null; if (! is_string($reasonCode) || trim($reasonCode) === '') { throw new InvalidArgumentException('Check reason_code is required.'); } $expiresAtParsed = null; if ($expiresAt !== null && trim($expiresAt) !== '') { try { $expiresAtParsed = CarbonImmutable::parse($expiresAt); } catch (\Throwable) { throw new InvalidArgumentException('expires_at must be a valid date-time.'); } if ($expiresAtParsed->isBefore(CarbonImmutable::now())) { throw new InvalidArgumentException('expires_at must be in the future.'); } } $acknowledgedAt = CarbonImmutable::now(); try { $ack = VerificationCheckAcknowledgement::create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'operation_run_id' => (int) $run->getKey(), 'check_key' => $checkKey, 'ack_reason' => $ackReason, 'expires_at' => $expiresAtParsed, 'acknowledged_at' => $acknowledgedAt, 'acknowledged_by_user_id' => (int) $actor->getKey(), ]); } catch (QueryException $e) { $ack = VerificationCheckAcknowledgement::query() ->where('operation_run_id', (int) $run->getKey()) ->where('check_key', $checkKey) ->first(); if (! $ack instanceof VerificationCheckAcknowledgement) { throw $e; } return $ack; } if ($ack->wasRecentlyCreated) { $workspace = $tenant->workspace; if ($workspace !== null) { $this->audit->log( workspace: $workspace, action: AuditActionId::VerificationCheckAcknowledged->value, context: [ 'tenant_id' => (int) $tenant->getKey(), 'operation_run_id' => (int) $run->getKey(), 'report_id' => (int) $run->getKey(), 'flow' => (string) $run->type, 'check_key' => $checkKey, 'reason_code' => $reasonCode, ], actor: $actor, resourceType: 'operation_run', resourceId: (string) $run->getKey(), ); } } return $ack; } /** * @return array */ private function reportForRun(OperationRun $run): array { $context = is_array($run->context) ? $run->context : []; $report = $context['verification_report'] ?? null; if (! is_array($report)) { throw new InvalidArgumentException('Verification report is missing.'); } $report = VerificationReportSanitizer::sanitizeReport($report); if (! VerificationReportSchema::isValidReport($report)) { throw new InvalidArgumentException('Verification report is invalid.'); } return $report; } /** * @param array $report * @return array */ private function findCheckByKey(array $report, string $checkKey): array { $checks = $report['checks'] ?? null; $checks = is_array($checks) ? $checks : []; foreach ($checks as $check) { if (! is_array($check)) { continue; } if (($check['key'] ?? null) === $checkKey) { return $check; } } throw new InvalidArgumentException('Check not found in verification report.'); } }