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, фиксируемой в том же согласованном изменении. Значения
наружу отдаются ревьюеру, но никогда не логируются.
| Поле | Значение |
|---|
| Сервис/контекст | profile |
| API area | admin (сотрудники ПИН) |
| Feature group | verification |
| Статус | draft |
| Документ | docs/06-services/profile/api/admin/view-verification-snapshot.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Operator(Admin) — ревьюер ПИН (Bearer JWT); не инвестор-владелец |
| Auth policy / Permissions | authenticated; permission profile.verification.review (permission.md: pin-manager/care/admin) |
| Scope (RBAC) | платформенный (кросс-доступ к чужой заявке инвестора — только по review-permission, ADR-0052) |
| Record-level | не применяется: ревьюер по определению читает чужую заявку; гейт — permission, а не владение |
| Tenant isolation | не применяется (данные инвестора вне tenant, ADR-0052) |
| Audit | read-sensitive, обязательный: каждая расшифровка — отдельная запись VerificationSnapshotAccessedEvent(Id, ReviewerUserId, AccessedAt) в схему audit (compliance/KYC); значения снапшота в логи/трейсы/метрики не попадают |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/admin/profile/verification/{requestId}/snapshot |
| Success status | 200 |
| Idempotency header | не применяется (побочный эффект — append-only аудит-запись; повтор порождает новую запись доступа осознанно) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ReviewerUserId | identity claims (sub) | Guid | Да | валидный JWT; NotEmpty | 401 без токена; должен иметь profile.verification.review (иначе 403) |
requestId | route | Guid | Да | NotEmpty; существующая заявка (шаг 3) | нет заявки → 404 (без раскрытия факта существования) |
Модель ответа VerificationSnapshotModel
Дом структуры снапшота — verification-request.md (VerificationSnapshotModel,
DocumentSnapshot) и person-document.md (typed-payload по типу, DocumentScan);
здесь — только проекция/дельта API: расшифрованные значения плюс presigned-ссылки на сканы. Модель
возвращается уже в открытом виде (ревьюеру), но не логируется.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
personId | Guid | Да | снапшот | — | Персона-предмет проверки (Relation = Self). |
personVersion | long | Да | снапшот | — | PersonEntity.Version на момент Submit — сверка «данные не менялись». |
firstName | string | Да | снапшот (расшифровка) | не логируется | Имя. |
lastName | string | Да | снапшот (расшифровка) | не логируется | Фамилия. |
middleName | string? | Нет | снапшот (расшифровка) | не логируется | Отчество, если есть. |
birthDate | DateOnly | Да | снапшот (расшифровка) | не логируется | Дата рождения (≥ 18 на момент Submit, INV-VRF-4). |
citizenshipCountryId | short? | Нет | снапшот | — | Гражданство — ссылка на справочник стран handbook. |
identityDocument | SnapshotDocumentView | Да | снапшот IdentityDocument | payload не логируется | Основной паспортный документ (INV-VRF-2). |
additionalDocuments | List<SnapshotDocumentView> | Да (может быть пустым) | снапшот AdditionalDocuments | payload не логируется | Дополнительные документы (≤ 10). |
requestScans | List<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?.
Алгоритм
- Контекст и доступ. Операцию выполняет ревьюер ПИН (
ReviewerUserId = sub); permission
profile.verification.review — нет → 403 (без раскрытия существования заявки). Tenant не применяется
(ADR-0052). Аудит доступа намеренно не идемпотентен.
- Проверка входа.
requestId и ReviewerUserId не пустые — иначе ValidationError (400).
- Что читаем и проверяем. Заявка должна существовать — иначе
PROFILE_VERIFICATION_NOT_FOUND (404).
Снапшот фиксируется только с момента Submit (INV-VRF-2), поэтому допустимы
Status ∈ {Submitted, InReview, Approved, Rejected}; при Status = Draft (или пустом SnapshotCipher) →
PROFILE_VERIFICATION_NO_SNAPSHOT (409). Персона и её живые документы не читаются — источник истины
ревью именно снапшот, а не текущее состояние персоны.
- Расшифровка снапшота.
SnapshotCipher расшифровывается ключом версии KeyVersion и разбирается в
VerificationSnapshotModel (миграция по SnapshotContractVersion — lazy); SnapshotDocumentView.payload
восстанавливается typed-моделью по type. Ключи из Vault; недоступность/ошибка ключа (fail-closed) →
PROFILE_CRYPTO_UNAVAILABLE (503) — доступ не состоялся, аудит-запись не создаётся.
- Регистрация доступа. Снапшот иммутабелен (INV-VRF-7) — поля заявки не изменяются. Единственная запись —
факт доступа: событие
VerificationSnapshotAccessedEvent(request.Id, ReviewerUserId, AccessedAt = now)
(без значений PII), персистируемое как append-only compliance-audit запись в схему audit (KYC — в
списке аудируемых compliance-сущностей). Kafka не задействован — аудит внутренний.
- Согласованность. Аудит-запись доступа фиксируется атомарно до отдачи расшифрованных данных:
снапшот виден клиенту, только если доступ зафиксирован в аудите.
- Обогащение ссылками и результат. После фиксации доступа для каждого скана документа и каждого скана
заявки (
ScanList) запрашивается presigned-ссылка в storage (TTL 5 мин, timeout 5 с, без retry):
downloadUrl и expiresAt. Storage недоступен — downloadUrl = null (деградация: расшифровка уже
произошла и зааудирована, срывать доступ бессмысленно; отдельный сигнал PROFILE_STORAGE_UNAVAILABLE).
Ответ — VerificationSnapshotModel, HTTP 200; тело в логи не пишется.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (Kafka не задействован; VerificationSnapshotAccessedEvent — только аудит-запись в схему audit) |
| External calls | криптосервис (Vault) — расшифровка снапшота (fail-closed → 503); storage — presigned-ссылки на сканы (TTL 5 мин, timeout 5 с, без retry, best-effort) |
| Cache / projections / search | none (расшифрованный снапшот не кэшируется — PII; ссылки короткоживущие) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
| — (authz) | 403 | Нет permission profile.verification.review | без раскрытия существования заявки | no retry |
ValidationError | 400 | Пустой requestId | RestApiValidationErrorResponse | no retry |
PROFILE_VERIFICATION_NOT_FOUND | 404 | Заявки с таким requestId нет | «Заявка не найдена» | no retry |
PROFILE_VERIFICATION_NO_SNAPSHOT | 409 | Status = Draft / SnapshotCipher == null (снапшот ещё не зафиксирован) | «Снапшот заявки ещё не сформирован» | no retry (появится после Submit) |
PROFILE_CRYPTO_UNAVAILABLE | 503 | Ключ PersonalDataCrypto недоступен (Vault, fail-closed) | «Расшифровка временно недоступна» | retry с backoff |
PROFILE_STORAGE_UNAVAILABLE | 200 (частично) | 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 (операция намеренно не идемпотентна).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Сущности: verification-request.md (
VerificationSnapshotModel,
DocumentSnapshot, VerificationSnapshotAccessedEvent, INV-VRF-2/7, безопасность),
person-document.md (typed-payload по типу, DocumentScan),
profile.md (денормализация VerificationStatus).
- Соседние операции ревью: list-verification-queue.md,
take-verification-in-review.md, approve-verification.md,
reject-verification.md; self-view — get-my-verification.md;
presigned-паттерн — get-document-scan-link.md.
- Permission: permission.md (
profile.verification.review);
сценарий — UC-PRF-003-submit-and-approve-verification.md.