Перейти к основному содержимому

GET /api/v1/admin/profile/verification/{requestId}/snapshot — Аудируемый просмотр снапшота KYC (viewVerificationSnapshot)

Назначение

Дать ревьюеру ПИН расшифрованный иммутабельный снапшот заявки на верификацию (KYC/AML): ФИО, дата рождения, паспортные и иные документы с typed-payload, сканы — чтобы принять решение Approve/Reject по тому, что было зафиксировано на момент Submit (даже если персона потом изменилась, INV-VRF-7, verification-request.md). В отличие от self-просмотра (get-my-verification.md, только метаданные) здесь SnapshotCipher расшифровывается — поэтому это привилегированная операция, и каждая расшифровка аудируется отдельной записью события VerificationSnapshotAccessedEvent, фиксируемой в том же согласованном изменении. Значения наружу отдаются ревьюеру, но никогда не логируются.

Metadata

ПолеЗначение
Сервис/контекстprofile
API areaadmin (сотрудники ПИН)
Feature groupverification
Статусdraft
Документdocs/06-services/profile/api/admin/view-verification-snapshot.md

Актор и доступ

ПроверкаЗначение
Actor typeOperator(Admin) — ревьюер ПИН (Bearer JWT); не инвестор-владелец
Auth policy / Permissionsauthenticated; permission profile.verification.review (permission.md: pin-manager/care/admin)
Scope (RBAC)платформенный (кросс-доступ к чужой заявке инвестора — только по review-permission, ADR-0052)
Record-levelне применяется: ревьюер по определению читает чужую заявку; гейт — permission, а не владение
Tenant isolationне применяется (данные инвестора вне tenant, ADR-0052)
Auditread-sensitive, обязательный: каждая расшифровка — отдельная запись VerificationSnapshotAccessedEvent(Id, ReviewerUserId, AccessedAt) в схему audit (compliance/KYC); значения снапшота в логи/трейсы/метрики не попадают

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/admin/profile/verification/{requestId}/snapshot
Success status200
Idempotency headerне применяется (побочный эффект — append-only аудит-запись; повтор порождает новую запись доступа осознанно)
CorrelationOpenTelemetry trace (traceparent)

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ReviewerUserIdidentity claims (sub)GuidДавалидный JWT; NotEmpty401 без токена; должен иметь profile.verification.review (иначе 403)
requestIdrouteGuidДаNotEmpty; существующая заявка (шаг 3)нет заявки → 404 (без раскрытия факта существования)

Модель ответа VerificationSnapshotModel

Дом структуры снапшота — verification-request.md (VerificationSnapshotModel, DocumentSnapshot) и person-document.md (typed-payload по типу, DocumentScan); здесь — только проекция/дельта API: расшифрованные значения плюс presigned-ссылки на сканы. Модель возвращается уже в открытом виде (ревьюеру), но не логируется.

ПолеТипОбязательностьИсточникMaskingОписание
personIdGuidДаснапшотПерсона-предмет проверки (Relation = Self).
personVersionlongДаснапшотPersonEntity.Version на момент Submit — сверка «данные не менялись».
firstNamestringДаснапшот (расшифровка)не логируетсяИмя.
lastNamestringДаснапшот (расшифровка)не логируетсяФамилия.
middleNamestring?Нетснапшот (расшифровка)не логируетсяОтчество, если есть.
birthDateDateOnlyДаснапшот (расшифровка)не логируетсяДата рождения (≥ 18 на момент Submit, INV-VRF-4).
citizenshipCountryIdshort?НетснапшотГражданство — ссылка на справочник стран handbook.
identityDocumentSnapshotDocumentViewДаснапшот IdentityDocumentpayload не логируетсяОсновной паспортный документ (INV-VRF-2).
additionalDocumentsList<SnapshotDocumentView>Да (может быть пустым)снапшот AdditionalDocumentspayload не логируетсяДополнительные документы (≤ 10).
requestScansList<SnapshotScanLink>Да (может быть пустым)VerificationRequestEntity.ScanList (plaintext jsonb)url не логируетсяСканы, приложенные к заявке сверх сканов документов.

SnapshotDocumentView — дельта над DocumentSnapshot (дом — verification-request.md): поля documentId, type (PersonDocumentType строкой), countryId (short — справочник стран), countryIsoCode2 (char(2) — юридический след на момент снапшота), payloadContractVersion, payload (typed-модель строго по type — дом person-document.md, без переописания) плюс scans: List<SnapshotScanLink> (вместо сырых DocumentScan — обогащены ссылками).

SnapshotScanLink — дельта над DocumentScan (дом — person-document.md): fileId, fileName, contentType, sizeBytes, uploadedAt плюс downloadUrl: string? (presigned, TTL 5 мин; null при недоступности storage — деградация без срыва доступа, шаг 8) и expiresAt: DateTimeOffset?.

Алгоритм

  1. Контекст и доступ. Операцию выполняет ревьюер ПИН (ReviewerUserId = sub); permission profile.verification.review — нет → 403 (без раскрытия существования заявки). Tenant не применяется (ADR-0052). Аудит доступа намеренно не идемпотентен.
  2. Проверка входа. requestId и ReviewerUserId не пустые — иначе ValidationError (400).
  3. Что читаем и проверяем. Заявка должна существовать — иначе PROFILE_VERIFICATION_NOT_FOUND (404). Снапшот фиксируется только с момента Submit (INV-VRF-2), поэтому допустимы Status ∈ {Submitted, InReview, Approved, Rejected}; при Status = Draft (или пустом SnapshotCipher) → PROFILE_VERIFICATION_NO_SNAPSHOT (409). Персона и её живые документы не читаются — источник истины ревью именно снапшот, а не текущее состояние персоны.
  4. Расшифровка снапшота. SnapshotCipher расшифровывается ключом версии KeyVersion и разбирается в VerificationSnapshotModel (миграция по SnapshotContractVersion — lazy); SnapshotDocumentView.payload восстанавливается typed-моделью по type. Ключи из Vault; недоступность/ошибка ключа (fail-closed) → PROFILE_CRYPTO_UNAVAILABLE (503) — доступ не состоялся, аудит-запись не создаётся.
  5. Регистрация доступа. Снапшот иммутабелен (INV-VRF-7) — поля заявки не изменяются. Единственная запись — факт доступа: событие VerificationSnapshotAccessedEvent(request.Id, ReviewerUserId, AccessedAt = now) (без значений PII), персистируемое как append-only compliance-audit запись в схему audit (KYC — в списке аудируемых compliance-сущностей). Kafka не задействован — аудит внутренний.
  6. Согласованность. Аудит-запись доступа фиксируется атомарно до отдачи расшифрованных данных: снапшот виден клиенту, только если доступ зафиксирован в аудите.
  7. Обогащение ссылками и результат. После фиксации доступа для каждого скана документа и каждого скана заявки (ScanList) запрашивается presigned-ссылка в storage (TTL 5 мин, timeout 5 с, без retry): downloadUrl и expiresAt. Storage недоступен — downloadUrl = null (деградация: расшифровка уже произошла и зааудирована, срывать доступ бессмысленно; отдельный сигнал PROFILE_STORAGE_UNAVAILABLE). Ответ — VerificationSnapshotModel, HTTP 200; тело в логи не пишется.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (Kafka не задействован; VerificationSnapshotAccessedEvent — только аудит-запись в схему audit)
External callsкриптосервис (Vault) — расшифровка снапшота (fail-closed → 503); storage — presigned-ссылки на сканы (TTL 5 мин, timeout 5 с, без retry, best-effort)
Cache / projections / searchnone (расшифрованный снапшот не кэшируется — PII; ссылки короткоживущие)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Нет/просрочен JWTбез телаre-login
— (authz)403Нет permission profile.verification.reviewбез раскрытия существования заявкиno retry
ValidationError400Пустой requestIdRestApiValidationErrorResponseno retry
PROFILE_VERIFICATION_NOT_FOUND404Заявки с таким requestId нет«Заявка не найдена»no retry
PROFILE_VERIFICATION_NO_SNAPSHOT409Status = Draft / SnapshotCipher == null (снапшот ещё не зафиксирован)«Снапшот заявки ещё не сформирован»no retry (появится после Submit)
PROFILE_CRYPTO_UNAVAILABLE503Ключ PersonalDataCrypto недоступен (Vault, fail-closed)«Расшифровка временно недоступна»retry с backoff
PROFILE_STORAGE_UNAVAILABLE200 (частично)storage недоступен на обогащении ссылокdownloadUrl = null у сканов (не срывает доступ)клиент повторяет за ссылкой позже

Совместимость и наблюдаемость

  • Новые optional поля ответа — additive; удаление/переименование/смена типа снапшота — breaking (рост SnapshotContractVersion, lazy-миграция при чтении, unknown enum type — fail-fast, person-document.md).
  • Logs: ReviewerUserId, requestId, Status, число сканов, latency — без значений PII, payload и downloadUrl; audit: запись VerificationSnapshotAccessedEvent в схему audit (обязательна, отдельная на каждый доступ); metrics: profile_api_operations_total{operation="viewVerificationSnapshot",result}, profile_crypto_decrypt_total{result}; traces — OTel. Dashboard profile-overview (алерт на всплеск ошибок расшифровки); runbook — ротация ключей шифрования.

Acceptance Criteria

  • Happy path. Given заявка со снапшотом (Status ∈ {Submitted, InReview, Approved, Rejected}), ревьюер с profile.verification.review, ключи доступны; When view; Then 200, снапшот расшифрован (VerificationSnapshotModel), аудит-запись VerificationSnapshotAccessedEvent зафиксирована до отдачи данных, сканы обогащены presigned-ссылками (TTL 5 мин); значения PII в логи не пишутся.
  • Снапшот на момент Submit. Given персона изменилась после Submit; When view; Then отдаются значения снапшота на момент Submit, а не текущие данные персоны (INV-VRF-7).
  • Нет снапшота. Given Status = Draft или пустой SnapshotCipher; When view; Then 409 PROFILE_VERIFICATION_NO_SNAPSHOT; аудит-запись доступа не создаётся.
  • Ключ недоступен. Given хранилище ключей недоступно (fail-closed); When view; Then 503 PROFILE_CRYPTO_UNAVAILABLE; доступ не состоялся, аудит-запись не создаётся.
  • Storage недоступен. Given ключи есть, но storage недоступен на обогащении ссылок; When view; Then 200 с downloadUrl = null у сканов; аудит доступа уже зафиксирован (расшифровка состоялась).
  • Нет прав. Given актор без profile.verification.review; When view; Then 403 без раскрытия существования заявки.
  • Каждый доступ аудируется. Given повторные просмотры одной заявки; When view N раз; Then создаётся N отдельных VerificationSnapshotAccessedEvent (операция намеренно не идемпотентна).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы