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 team | PIN 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 context | greenfield; будущий worker, заявленный README и domain-доками (KeyVersion — поле каждого cipher-агрегата) |
Классификация
| Поле | Значение |
|---|
| Сервис | profile |
| Тип job | recurring 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 size | BatchSize строк на выборку (default 200); прогон повторяет выборку по каждому типу, пока батч полон |
| Max duration | MaxRunDuration (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, публичного входа нет).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
PollingInterval | TimeSpan | Да | config | Период проверки CurrentKeyVersion (default 5 мин). |
BatchSize | int | Да | config | Размер выборки строк (> 0, default 200). |
MaxDegreeOfParallelism | int | Да | config | Ограничение параллелизма (default 1). |
InterBatchDelay | TimeSpan | Да | config | Пауза между батчами — уступить self-операциям (default 200 мс). |
MaxRunDuration | TimeSpan | Да | config | Верхняя граница одного прогона (default 10 мин). |
Enum ReEncryptTarget (порядок обхода агрегатов)
| Значение | Агрегат (дом) | Cipher-поля (перешифровываются) | Условие выборки |
|---|
Person | PersonEntity | EncryptedPersonName (FirstNameCipher/LastNameCipher/MiddleNameCipher?), BirthDateCipher? (INV-PRS-8: все cipher одним ключом) | KeyVersion < CurrentKeyVersion |
PersonDocument | PersonDocumentEntity | PayloadCipher | KeyVersion < CurrentKeyVersion |
RequisiteTemplate | RequisiteTemplateEntity | ContactEmailCipher?, ContactPhoneCipher? (оба nullable) | KeyVersion < CurrentKeyVersion |
VerificationRequest | VerificationRequestEntity | SnapshotCipher (иммутабельный снапшот; 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.
- Контекст и доступ. Актор — system,
correlationId = jobRunId, скан вне tenant (ADR-0052). cur
читается из провайдера ключей PersonalDataCrypto. Отдельный idempotency-lookup не нужен:
идемпотентность даёт сам критерий выборки KeyVersion < cur (уже перешифрованные записи в выборку не
попадают, шаг 3).
- Проверка входа. Внешних данных нет;
ReEncryptRotationOptions проверяется при старте
(BatchSize > 0, cur >= 1) → иначе fail-fast хоста. Существование/статус агрегатов проверять нечего:
выборка сама отбирает нужные строки; архивные/терминальные тоже перешифровываются (PII в них
хранится — soft-delete, KYC-история). Доступность ключей проверяется на шаге 4 (fail-closed).
- Загрузка (под блокировкой). По каждому
ReEncryptTarget выбрать порцию строк с KeyVersion < cur
(для VerificationRequest — дополнительно SnapshotCipher != null), стабильный порядок по Id, порция
BatchSize, с FOR UPDATE SKIP LOCKED — строки, взятые параллельной self-операцией/sibling-pod-ом,
пропускаются. Пустой батч → переход к следующему target.
- Бизнес-правила и маппинг. Для каждой записи батча: каждое непустое cipher-поле расшифровывается
старым ключом строки и заново шифруется текущим ключом
cur. Plaintext идентичен исходному (INV-VRF-7 —
значения снапшота неизменны; INV-PRS-8 — все cipher записи одним ключом). Собирается набор новых
cipher-значений + KeyVersion = cur. Если у RequisiteTemplate оба контакта пустые — крипто-операции
нет, обновляется только KeyVersion (запись покидает backlog). Бизнес-Version не инкрементируется,
доменные события не поднимаются — операция техническая, не бизнес-правка. Недоступность старого/нового
ключа → ошибка шифрования → шаг 7 (rollback, fail-closed).
- Что меняется. На каждую запись обновляются только cipher-колонки +
KeyVersion; точечное
обновление колонок минует audit/version-стемпинг: Version, UpdatedAt, UpdatedByUserId, доменные
события не затрагиваются; RowVersion (xmin) обновляется PostgreSQL автоматически — служит
optimistic-токеном. Порядок в батче — по Id.
- События. Не публикуются — техническая перешифровка (
KeyVersion потребителям не виден, PII в Kafka
не выносится — person.md, ../README.md); outbox не задействован.
- Согласованность. Батч одного target фиксируется в одном согласованном изменении:
FOR UPDATE-локи
держатся до коммита → конкурентная self-операция над этими строками ждёт/пропускается, sibling-pod их
скипает. Per-aggregate атомарность обеспечена: каждый агрегат — одна строка, все его cipher-поля +
KeyVersion переезжают вместе (нет полу-перешифрованных записей). Любая ошибка Decrypt/Encrypt
откатывает весь батч (fail-closed) — job приостанавливается, алерт «ошибки PersonalDataCrypto > 0».
- Результат. Job отдаёт не наружу, а метрики прогона (перешифровано по каждому target, остаток
backlog, длительность). Пустой прогон — no-op success. Restart-safe: прогресс = сам
KeyVersion
строк (докуда дошёл — то и перешифровано); отдельный checkpoint не нужен.
Граница Транзакции
| Элемент | Правило |
|---|
| Unit of work | per-batch (одна транзакция на батч одного ReEncryptTarget); внутри — все агрегаты батча, каждый атомарно (одна строка) |
| Сохраняемое состояние | cipher-колонки + KeyVersion затронутых строк; Version/UpdatedAt/audit — не меняются; outbox не пишется |
| External calls | внешних вызовов внутри транзакции нет; Decrypt/Encrypt — in-memory (ключи закешированы провайдером) |
| Частичная ошибка | fail-closed: ошибка crypto/записи откатывает весь батч; строки остаются на старом KeyVersion, rollback снимает FOR UPDATE-локи, следующий tick повторяет |
Идемпотентность И Retry
| Сценарий | Правило |
|---|
| Job restart | Checkpoint не нужен — «прогресс» это сам KeyVersion строк; рестарт заново фильтрует KeyVersion < cur, уже перешифрованные (= cur) не попадают |
| Duplicate item | Невозможно в прогоне: перешифрованная строка не проходит KeyVersion < cur; повторный проход даёт 0 совпадений |
| Параллельные инстансы / self-операции | Безопасны: FOR UPDATE SKIP LOCKED разводит строки; одну запись перешифровывает ровно один инстанс, конкурентная self-правка либо взяла lock первой (job скипнёт, возьмёт следующим tick), либо ждёт commit job и пишет уже cur |
| Гонка с self-правкой PII | self-операция шифрует своим cur и инкрементирует бизнес-Version; RowVersion (xmin) на записи job фиксирует изменение — конфликт откатывает батч, следующий tick перечитывает уже актуальную строку |
Недоступность ключей PersonalDataCrypto | Retry с backoff (fail-closed: батч не коммитится); алерт «ошибки PersonalDataCrypto > 0»; ручное — восстановить доступ к Vault/k8s |
| Повторяемая инфраструктурная ошибка | Инфра-сбой (запись в БД) — откат снимает локи, следующий tick повторяет; без бесконечного retry внутри прогона |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | job started (jobRunId, cur); batch processed (target, rows, reencrypted); batch failed (target, exception без plaintext); job completed (перешифровано по target, backlogRemaining, durationMs). Значения PII не логируются (даже маскированные) — только Id/target/имена cipher-полей |
| Metrics | profile_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 |
| Traces | span на прогон с 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-cipher | Person без 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.Background — document-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).