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

Background Job: ReEncryptRotationJob

Назначение

ReEncryptRotationJob — фоновая перешифровка PII при ротации ключей PersonalDataCrypto (проект Pin.Profile.Background, заявлен в ../README.md «Проекты»/«Runbooks»). При вводе нового активного KeyVersion job переводит все записи с cipher-полями, зашифрованными старым ключом, на текущий ключ: Decrypt(старый KeyVersion) → Encrypt(CurrentKeyVersion) — plaintext не меняется, меняется только шифротекст и KeyVersion записи.

Зачем это нужно:

  • Доступность расшифровки/записи PII (fail-closed). Единый актуальный KeyVersion гарантирует, что self-операции и internal-снапшоты не наткнутся на запись, зашифрованную выведенным из оборота ключом (../README.md, зависимость Vault/k8s: недоступность ключей → операции с PII отклоняются).
  • Поддержка crypto-erase. Когда backlog старого KeyVersion сведён к нулю (ни одна живая запись на него не ссылается), старый ключ можно безопасно уничтожить (crypto-shredding). Per-subject стирание PII — отдельный admin-регламент (ADR-0053), здесь не выполняется.

Дом полей/инвариантов cipher — в domain-доках; ниже перечислена только дельта (какие cipher-поля + KeyVersion перешифровываются), таблицы полей не дублируются.

MetadataЗначение
Owning teamPIN Platform Core
Статус документаdraft
Область действияконтекст profile, таблицы profile.persons / person_documents / requisite_templates / verification_requests, проект Pin.Profile.Background
Assumptions/ограниченияротация — редкое событие (ручной ввод нового ключа в Vault/k8s); объёмы малы (ADR-0056: до 1M пользователей, агрегаты грузятся построчно, партиционирования нет); HMAC-ключ NumberHash независим от ключа шифрования и не ротируется этим job; CurrentKeyVersion монотонно не убывает (откат ключа не поддерживается — записи с KeyVersion > cur не перевыбираются и остаются на своём ключе); провайдер ключей обязан хранить все версии ≤ max(KeyVersion) живых записей — иначе расшифровка backlog-строк невозможна (fail-closed)
ЗависимостиPostgreSQL (profile); PersonalDataCrypto + провайдер ключей (Vault/k8s secrets — источник CurrentKeyVersion). Kafka не используется (событий job не публикует)
Change contextgreenfield; будущий worker, заявленный README и domain-доками (KeyVersion — поле каждого cipher-агрегата)

Классификация

ПолеЗначение
Сервисprofile
Тип jobrecurring polling-worker (внутренний таймер) + ручной триггер ротации (runbook)
Triggerтаймер каждые PollingInterval; на каждом tick CurrentKeyVersion читается из провайдера ключей — при его росте появляется backlog, который job разгребает
Характер операциитехническая перешифровка минует доменные методы: бизнес-Version не инкрементируется, доменные события не поднимаются (ADR-0056 simplicity)
Акторsystem (внешнего входа нет; конфиг ReEncryptRotationOptions проверяется при старте хоста)
Критичностьmedium (техническая maintenance-операция вне денежного пути; деградация задерживает вывод старого ключа, но не блокирует ЛК)

Расписание И Locking

АспектПравило
Scheduleкаждые PollingInterval (default 5 мин; UTC); при росте CurrentKeyVersion backlog разгребается за несколько tick-ов
Distributed lockне требуется — конкурентность даёт FOR UPDATE SKIP LOCKED на уровне строк: job берёт строки, не залоченные self-операциями/sibling-pod-ами, дубль-перешифровки нет. Redis отсутствует (ADR-0056 §4). Опционально один активный инстанс — PostgreSQL advisory lock Pin:lock:job:scheduled:profile-reencrypt (не Redis), но по построению не обязателен
Ограничение параллелизмаMaxDegreeOfParallelism (default 1) + InterBatchDelay — чтобы job не конкурировал за строки с интерактивными self-операциями (ЛК p99 < 300 мс приоритетнее фоновой перешифровки)
Batch sizeBatchSize строк на выборку (default 200); прогон повторяет выборку по каждому типу, пока батч полон
Max durationMaxRunDuration (default 10 мин) — предохранитель; остаток берёт следующий tick (прогресс durable, см. идемпотентность)
Cancellationсигнал остановки хоста учитывается между батчами — graceful stop, без обрыва открытой транзакции

Входная Модель ReEncryptRotationOptions (config)

Внешнего runtime-входа нет; CurrentKeyVersion берётся из провайдера ключей PersonalDataCrypto, не из запроса. Actor — system; контекст вне tenant (ADR-0052: инвестор вне tenant) — скан идёт по всем записям безотносительно OwnerUserId (record-level фильтры не применяются; задокументированный system-maintenance cross-owner scan, публичного входа нет).

ПолеТипОбязательностьИсточникОписание
PollingIntervalTimeSpanДаconfigПериод проверки CurrentKeyVersion (default 5 мин).
BatchSizeintДаconfigРазмер выборки строк (> 0, default 200).
MaxDegreeOfParallelismintДаconfigОграничение параллелизма (default 1).
InterBatchDelayTimeSpanДаconfigПауза между батчами — уступить self-операциям (default 200 мс).
MaxRunDurationTimeSpanДаconfigВерхняя граница одного прогона (default 10 мин).

Enum ReEncryptTarget (порядок обхода агрегатов)

ЗначениеАгрегат (дом)Cipher-поля (перешифровываются)Условие выборки
PersonPersonEntityEncryptedPersonName (FirstNameCipher/LastNameCipher/MiddleNameCipher?), BirthDateCipher? (INV-PRS-8: все cipher одним ключом)KeyVersion < CurrentKeyVersion
PersonDocumentPersonDocumentEntityPayloadCipherKeyVersion < CurrentKeyVersion
RequisiteTemplateRequisiteTemplateEntityContactEmailCipher?, ContactPhoneCipher? (оба nullable)KeyVersion < CurrentKeyVersion
VerificationRequestVerificationRequestEntitySnapshotCipher (иммутабельный снапшот; INV-VRF-7: значения не меняются, plaintext тот же)KeyVersion != null && KeyVersion < CurrentKeyVersion

Не трогаются: NumberHash (HMAC, отдельный стабильный ключ), NumberMask, ScanList, Composition, CitizenshipCountryId/Gender (открытые не-PII) — вне ротации ключа шифрования. Draft-заявки без снапшота (SnapshotCipher == null, KeyVersion == null) в выборку не попадают — их KeyVersion проставляется актуальным при Submit.

Алгоритм

Один прогон обходит ReEncryptTarget по порядку; для каждого — повторяет батч, пока в нём есть строки. cur = CurrentKeyVersion.

  1. Контекст и доступ. Актор — system, correlationId = jobRunId, скан вне tenant (ADR-0052). cur читается из провайдера ключей PersonalDataCrypto. Отдельный idempotency-lookup не нужен: идемпотентность даёт сам критерий выборки KeyVersion < cur (уже перешифрованные записи в выборку не попадают, шаг 3).
  2. Проверка входа. Внешних данных нет; ReEncryptRotationOptions проверяется при старте (BatchSize > 0, cur >= 1) → иначе fail-fast хоста. Существование/статус агрегатов проверять нечего: выборка сама отбирает нужные строки; архивные/терминальные тоже перешифровываются (PII в них хранится — soft-delete, KYC-история). Доступность ключей проверяется на шаге 4 (fail-closed).
  3. Загрузка (под блокировкой). По каждому ReEncryptTarget выбрать порцию строк с KeyVersion < cur (для VerificationRequest — дополнительно SnapshotCipher != null), стабильный порядок по Id, порция BatchSize, с FOR UPDATE SKIP LOCKED — строки, взятые параллельной self-операцией/sibling-pod-ом, пропускаются. Пустой батч → переход к следующему target.
  4. Бизнес-правила и маппинг. Для каждой записи батча: каждое непустое cipher-поле расшифровывается старым ключом строки и заново шифруется текущим ключом cur. Plaintext идентичен исходному (INV-VRF-7 — значения снапшота неизменны; INV-PRS-8 — все cipher записи одним ключом). Собирается набор новых cipher-значений + KeyVersion = cur. Если у RequisiteTemplate оба контакта пустые — крипто-операции нет, обновляется только KeyVersion (запись покидает backlog). Бизнес-Version не инкрементируется, доменные события не поднимаются — операция техническая, не бизнес-правка. Недоступность старого/нового ключа → ошибка шифрования → шаг 7 (rollback, fail-closed).
  5. Что меняется. На каждую запись обновляются только cipher-колонки + KeyVersion; точечное обновление колонок минует audit/version-стемпинг: Version, UpdatedAt, UpdatedByUserId, доменные события не затрагиваются; RowVersion (xmin) обновляется PostgreSQL автоматически — служит optimistic-токеном. Порядок в батче — по Id.
  6. События. Не публикуются — техническая перешифровка (KeyVersion потребителям не виден, PII в Kafka не выносится — person.md, ../README.md); outbox не задействован.
  7. Согласованность. Батч одного target фиксируется в одном согласованном изменении: FOR UPDATE-локи держатся до коммита → конкурентная self-операция над этими строками ждёт/пропускается, sibling-pod их скипает. Per-aggregate атомарность обеспечена: каждый агрегат — одна строка, все его cipher-поля + KeyVersion переезжают вместе (нет полу-перешифрованных записей). Любая ошибка Decrypt/Encrypt откатывает весь батч (fail-closed) — job приостанавливается, алерт «ошибки PersonalDataCrypto > 0».
  8. Результат. Job отдаёт не наружу, а метрики прогона (перешифровано по каждому target, остаток backlog, длительность). Пустой прогон — no-op success. Restart-safe: прогресс = сам KeyVersion строк (докуда дошёл — то и перешифровано); отдельный checkpoint не нужен.

Граница Транзакции

ЭлементПравило
Unit of workper-batch (одна транзакция на батч одного ReEncryptTarget); внутри — все агрегаты батча, каждый атомарно (одна строка)
Сохраняемое состояниеcipher-колонки + KeyVersion затронутых строк; Version/UpdatedAt/audit — не меняются; outbox не пишется
External callsвнешних вызовов внутри транзакции нет; Decrypt/Encrypt — in-memory (ключи закешированы провайдером)
Частичная ошибкаfail-closed: ошибка crypto/записи откатывает весь батч; строки остаются на старом KeyVersion, rollback снимает FOR UPDATE-локи, следующий tick повторяет

Идемпотентность И Retry

СценарийПравило
Job restartCheckpoint не нужен — «прогресс» это сам KeyVersion строк; рестарт заново фильтрует KeyVersion < cur, уже перешифрованные (= cur) не попадают
Duplicate itemНевозможно в прогоне: перешифрованная строка не проходит KeyVersion < cur; повторный проход даёт 0 совпадений
Параллельные инстансы / self-операцииБезопасны: FOR UPDATE SKIP LOCKED разводит строки; одну запись перешифровывает ровно один инстанс, конкурентная self-правка либо взяла lock первой (job скипнёт, возьмёт следующим tick), либо ждёт commit job и пишет уже cur
Гонка с self-правкой PIIself-операция шифрует своим cur и инкрементирует бизнес-Version; RowVersion (xmin) на записи job фиксирует изменение — конфликт откатывает батч, следующий tick перечитывает уже актуальную строку
Недоступность ключей PersonalDataCryptoRetry с backoff (fail-closed: батч не коммитится); алерт «ошибки PersonalDataCrypto > 0»; ручное — восстановить доступ к Vault/k8s
Повторяемая инфраструктурная ошибкаИнфра-сбой (запись в БД) — откат снимает локи, следующий tick повторяет; без бесконечного retry внутри прогона

Наблюдаемость

ТипПравило
Logsjob started (jobRunId, cur); batch processed (target, rows, reencrypted); batch failed (target, exception без plaintext); job completed (перешифровано по target, backlogRemaining, durationMs). Значения PII не логируются (даже маскированные) — только Id/target/имена cipher-полей
Metricsprofile_crypto_reencrypt_progress (gauge — осталось строк с KeyVersion < cur, label target; сходится к 0); profile_crypto_reencrypt_processed_total (counter, label target); profile_crypto_reencrypt_run_duration_ms (histogram); profile_crypto_reencrypt_errors_total (counter, label `reason=key_unavailable
Tracesspan на прогон с jobRunId, атрибуты target, batchSize, cur; вложенные span на транзакцию батча (correlationId)
Alertsошибки PersonalDataCrypto (недоступность ключей) > 0 (errors_total{reason=key_unavailable} — см. ../README.md «Alerts»); backlog не сходится к 0 (profile_crypto_reencrypt_progress не убывает N циклов после роста cur); отсутствие успешного прогона (worker стоит)

SLO Цикла

ПоказательЦельОбоснование
Полная перешифровка после ротации100% записей на cur в течение 24 ч после ввода нового ключаобъёмы малы (ADR-0056); проверяется profile_crypto_reencrypt_progress → 0
Backlog после дренажанет строк с KeyVersion < cur (все target) устойчивопредусловие безопасного вывода старого ключа (crypto-shredding)
Влияние на self-операциифоновая перешифровка не деградирует ЛК p99 < 300 мсMaxDegreeOfParallelism/InterBatchDelay + FOR UPDATE SKIP LOCKED (self-операции не блокируются job)
Идемпотентность0 повторных перешифровок и 0 инкрементов бизнес-Version при рестартах/N инстансахкритерий KeyVersion < cur + точечное обновление колонок без audit-стемпинга
Сохранность значенийplaintext после перешифровки идентичен исходному (в т.ч. иммутабельный снапшот, INV-VRF-7)Decrypt→Encrypt того же значения; проверяется тестом round-trip

Связанный Runbook

СценарийRunbook
Ввод нового активного KeyVersion (ротация)добавить ключ в Vault/k8s, повысить CurrentKeyVersion; job начнёт дренаж на следующем tick; дождаться profile_crypto_reencrypt_progress = 0 перед выводом старого ключа («ротация ключей шифрования — re-encrypt job по KeyVersion», ../README.md)
Ошибки PersonalDataCrypto > 0проверить доступность Vault/k8s secrets и наличие старого + нового ключей; job приостановлен (fail-closed), возобновится после восстановления
Backlog не сходитсяпроверить блокировки PostgreSQL/долгие self-транзакции, увеличить BatchSize/частоту или временно MaxDegreeOfParallelism
Crypto-erase / вывод старого ключауничтожать старый KeyVersion только при пустом backlog по всем target; per-subject стирание — admin-регламент (ADR-0053)

Тесты

ТестОжидание
Empty batchнет строк с KeyVersion < cur — no-op success, записей не тронуто
Round-trip значенияпосле перешифровки Decrypt(newCipher, cur) == исходный plaintext по всем cipher-полям (Person/Document/Template/Verification)
Иммутабельный снапшот (INV-VRF-7)SnapshotCipher перешифрован, но расшифрованный VerificationSnapshotModel побайтно совпадает; Version/Status/SubmittedAt не изменены
Version/audit не тронутыбизнес-Version, UpdatedAt, UpdatedByUserId записи не меняются; xmin (RowVersion) обновлён
Nullable-cipherPerson без BirthDateCipher/MiddleNameCipher и Template с null-контактами — перешифрованы только непустые, KeyVersion проставлен
Draft-заявка без снапшотаVerificationRequest с SnapshotCipher == null не выбирается (KeyVersion остаётся null)
Откат ключа (cur уменьшился)записи с KeyVersion > cur не перевыбираются и не «даунгрейдятся»; job — no-op по ним; расшифровка требует наличия их (более новых) ключей у провайдера
Пропавший старый ключDecrypt строки со старым KeyVersion невозможен → батч откатан (fail-closed), errors_total{reason=key_unavailable} растёт, алерт; строки остаются на старом ключе
Идемпотентность/рестартповторный прогон перешифровывает 0 строк
Параллельные инстансы/self-гонкадве копии worker-а и конкурентная self-правка не перешифровывают строку дважды (FOR UPDATE SKIP LOCKED + xmin)
Недоступность ключаDecrypt/Encrypt бросает → батч откатан, строки на старом KeyVersion, errors_total{reason=key_unavailable} растёт, алерт
Cancellationотмена между батчами — graceful stop, открытая транзакция не рвётся, закоммиченное сохранено

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

  • Дом полей/инвариантов cipher (KeyVersion + cipher-поля): ../domain/person.md (INV-PRS-8 CipherKeyConsistency, EncryptedPersonName), ../domain/person-document.md (PayloadCipher, NumberHash — отдельный HMAC-ключ), ../domain/requisite-template.md (ContactEmailCipher/ContactPhoneCipher), ../domain/verification-request.md (INV-VRF-7 ImmutableSnapshot).
  • Обзор/эксплуатация: ../README.md («Проекты», «Runtime И Эксплуатация», «Безопасность»); соседний worker Pin.Profile.Backgrounddocument-expiry-reminder; consumer — ../consumers/identity-users-sync.md (шифрование ФИО Self).
  • Решения: ADR-0052 (инвестор вне tenant), ADR-0053 (администратор платформы / crypto-erase PII), ADR-0056 §4 (locks — Postgres/FOR UPDATE SKIP LOCKED, не Redis), ADR-0031 (background jobs).