Вступление

Это вторая статья про pg_anon — разрабатываемый нами open‑source инструмент для маскирования данных в PostgreSQL. В первой мы разбирали базовые принципы и показывали работу на компактном примере, а за прошедший год проект заметно вырос: появились частичный дамп и восстановление, поддержка сложных схем PostgreSQL, REST API и новый CLI. Меня зовут Максим Ибрагимов, я из команды Tantor Labs и работаю над pg_anon. В этой статье посмотрим, что изменилось, и пройдём весь жизненный цикл работы с инструментом на более реалистичной базе.

Прежде чем начнем

Освежим в памяти, что такое маскирование и для чего оно нужно. Представим типичную ситуацию. В продовой БД интернет‑банка хранятся данные клиентов: ФИО, телефоны, адреса электронной почты, номера документов и история операций. Разработчикам нужно воспроизвести дефект на тестовом стенде. Аналитикам — проверить новую отчётность. Подрядчику — помочь с интеграцией.

Самый простой путь — сделать копию продовой БД. Но вместе со структурой и бизнес‑данными в неё попадут реальные персональные данные (ПДн) клиентов. К тому же во многих компаниях доступ к продовой БД сильно ограничен, а передача её копий подрядчикам или во внешние контуры может нарушать внутренние политики безопасности и требования законодательства о персональных данных. Одна копия прода, осевшая на ноутбуке подрядчика — это уже инцидент с персональными данными со всеми вытекающими. Поэтому между продом и тестовыми средами обычно появляется дополнительный этап — маскирование данных. Оно применяется везде, где нужна копия прода без реальных персональных данных: dev/test‑контуры, демо, передача подрядчикам.

Стоит обозначить три смежных термина, которые часто путают:

  • Маскирование — это техника замены значения в поле на правдоподобное;

  • Псевдонимизация — уже состояние данных, при котором прямые идентификаторы удалены или заменены, но связи между записями сохранены, поэтому при наличии дополнительных сведений человека можно повторно идентифицировать;

  • Анонимизация (обезличивание) — состояние, когда определить принадлежность данных конкретному человеку нельзя никакими разумными средствами.

Регуляторика оперирует похожими понятиями, хотя формулировки и планка различаются: 152-ФЗ говорит об «обезличивании», GDPR — о псевдонимизации и анонимизации. В юридические тонкости мы здесь не уходим и используем эти термины в инженерном смысле. Сам инструмент — техническое средство: он помогает убрать персональные данные из копии, но достаточно ли этого для требований 152-ФЗ в вашем случае, определяет не утилита, а то, как настроен процесс и что решат ваши юристы или ответственный за защиту данных.

pg_anon выполняет именно маскирование: подменяет значения в полях, а структуру и связи сохраняет. То есть по умолчанию на выходе получается псевдонимизированная копия, а не обезличенная. Станет ли она обезличенной, зависит от того, как настроен словарь. К этому вернёмся при сравнении источника с приёмником.

Содержание

1. Что нового за год

1.1. Установка и запуск

Ранее установка сводилась к запуску проекта напрямую через Python. Это работало, но усложняло автоматизацию из‑за жёсткой зависимости от структуры самого проекта. Теперь pg_anon собирается и распространяется как обычный Python‑пакет.

Минимальные требования к окружению — Python 3.11 и выше. На стороне СУБД pg_anon работает с PostgreSQL начиная с 9.6 и со всеми совместимыми форками на том же ядре.

Базовая поставка содержит сам CLI и все режимы работы. Дополнительные возможности вынесены в опциональные группы зависимостей, чтобы по умолчанию не тянуть в окружение ничего лишнего. Варианты установки:

  • Базовая — pip install "pg_anon==1.11.0";

  • Базовая и зависимости для работы REST API сервиса — pip install "pg_anon[api]==1.11.0";

  • Базовая и зависимости для разработки и тестирования — pip install "pg_anon[dev]==1.11.0";

  • Базовая и все зависимости — pip install "pg_anon[dev,api]==1.11.0".

После установки в системе появляются две команды:

  • pg_anon — для всей основной работы;

  • pg_anon_api — для запуска REST‑сервиса. Предварительно потребуется поставить группу зависимостей [api].

Каждый режим работы теперь оформлен как отдельная подкоманда. Это даёт привычный для CLI‑утилит запуск: команда + подкоманда + опции. Полный список подкоманд можно получить из встроенной справки:

pg_anon --help

В выводе будут все доступные режимы: init, create-dict, dump, restore, view-fields, view-data, sync-struct-dump, sync-data-dump, sync-struct-restore, sync-data-restore. У каждой подкоманды свой набор опций и своя справка по ним.

Раньше всё это запускалось через единственную точку входа python -m pg_anon с обязательной опцией --mode. При этом --help показывал параметры всех режимов сразу, и понять, какая опция к какому режиму относится, на глаз было почти нереально. Старый синтаксис оставлен ради обратной совместимости. Если у вас есть скрипты с прошлых версий, они продолжат работать без правок.

Служебные файлы запуска pg_anon складывает в текущий каталог. В него же, в подпапку pg_anon_runs, попадают логи, метаданные операции и при необходимости копии словарей. Корень для них переопределяется переменной PG_ANON_HOME. Это удобно при контейнеризации, когда служебные данные нужно вынести в отдельный том.

1.2. Частичный дамп и восстановление

Ранее утилита работала только с целой базой. dump собирал всё, restore всё разворачивал. Если из прода нужно было утащить, скажем, только клиентов и счета без аудита и тикетов, приходилось самостоятельно городить обходные пути. Чтобы закрыть этот кейс, в утилиту добавили режим частичного дампа и восстановления.

Точечно указать таблицы можно через словари. Белый список задаёт, что включить, а чёрный - наоборот, что исключить. Эти списки передаются через опции:

  • --partial-tables-dict-file=path/to/whitelist.py;

  • --partial-tables-exclude-dict-file=path/to/blacklist.py.

Чёрные и белые списки можно использовать раздельно и совместно. Например, выбрать только одну из множества схем и достать все таблицы, кроме ненужных. Также их можно применять и на этапе дампа, и на этапе восстановления независимо. Ниже приведена схема, демонстрирующая состав таблиц в базах данных и в дампе, при использовании разных подходов:

Главная тонкость кроется в окружении таблиц. Любая нетривиальная таблица обычно тащит за собой кучу зависимостей, среди них пользовательские типы, домены, range‑типы, кастомные функции, триггеры, операторы и агрегаты. Если просто взять DDL выбранных таблиц и развернуть на чистой целевой БД, CREATE TABLE упадёт на первой же колонке нестандартного типа.

Этот случай pg_anon обрабатывает автоматически. При частичном дампе он отдельно собирает DDL вспомогательных объектов из задействованных схем и кладёт их в метаданные дампа. Во время восстановления они создаются перед самими таблицами, поэтому на приёмнике не нужно заранее вручную заводить домены, типы и функции — restore поднимает их сам.

Демонстрация работы частичного дампа и восстановления приведена в разделе 2.9.

1.3. Новые опции запуска

За год у dump и restore появилось несколько опций, которые закрывают частые операционные мелочи. Раньше ради них приходилось руками лезть в целевую БД или в обёртку вокруг утилиты, теперь это делается прямо при запуске.

Подготовка целевой БД перед восстановлением

При повторных прогонах целевая база обычно уже не пустая, и restore спотыкался о существующие объекты. Для решения этой проблемы были добавлены две взаимоисключающих опции:

  • --clean-db — удаляет из целевой БД те объекты, что есть в дампе, и заливает их заново. Остальную структуру не трогает. Полезен для точечной перезаливки в существующую базу;

  • --drop-db — пересоздаёт целевую базу целиком, с нуля. Полезен, когда проще снести всё и развернуть заново.

Права

--ignore-privileges — пропускает восстановление GRANT‑ов и владельцев из исходной базы. Полезно, когда прод и тест используют разные роли. Например, в проде существуют роли billing_rw и billing_ro, а в тестовом окружении их просто нет. Без этой опции восстановление падало бы на отсутствующих ролях.

Проброс опций в pg_dump / pg_restore

Под капотом pg_anon опирается на клиентские утилиты PostgreSQL, и иногда нужно докинуть им опцию, которой у самого pg_anon нет. Для этого есть сквозные опции:

  • --pg-dump-options — передаёт произвольные опции в pg_dump на этапе дампа;

  • --pg-restore-options — передаёт произвольные опции для pg_restore при восстановлении.

Всё, что туда передано, уходит в нижележащую утилиту как есть.

Отладка и воспроизведение

--save-dicts — складывает все входные и выходные словари запуска в каталог runs. Удобно, когда нужно разобраться, почему результат не такой, как ожидалось, или повторить операцию ровно с теми же словарями.

1.4. Поддержка сложных схем PostgreSQL

Реальные продовые схемы редко обходятся плоскими таблицами и стандартными колонками. Партиции, вычисляемые колонки, хитрые имена объектов, системные триггеры встречаются сплошь и рядом. За год мы прошлись по таким конструкциям и закрыли крайние случаи, на которых утилита могла споткнуться. Ниже разобраны основные группы.

Иерархия таблиц

Партиционированные таблицы проходят весь цикл дампа и восстановления корректно. При полном восстановлении правильно воссоздаются внешние ключи. При частичном дампе ограничения навешиваются прямо на партиции. Маскирование применяется к данным внутри каждой из них, а избыточные индексы в дамп не попадают. Поддерживается и наследование через INHERITS. Раньше выгрузка напрямую через родительскую таблицу задваивала строки дочерних — на таблице событий за год это лишние миллионы записей в копии. Теперь pg_anon разворачивает дочерние таблицы без задвоения.

Специальные колонки

Колонки GENERATED ALWAYS и identity‑последовательности штатно проходят дамп и восстановление. Вычисляемые значения не заливаются напрямую, иначе восстановление упало бы: присвоить такой колонке значение нельзя, оно считается из формулы. Последовательности при этом сохраняют своё состояние. Такие колонки часто используются для вычисляемых сумм, комиссий или агрегированных значений.

Имена объектов

Спецсимволы в именах экранируются везде как надо. Колонка вроде «БИК‑код» или таблица «Order Details» с пробелом раньше ломала генерируемый SQL прямо на кавычках, теперь такие идентификаторы проходят весь цикл без сюрпризов.

1.5. Переработка движка дампа

Дамп и сканирование переписали с multiprocessing на чистый asyncio. Отказ от multiprocessing начался ещё в 1.9.6, а к 1.11.0 модель довели до текущего вида. Само по себе это не ускорение, а смена модели: меньше кода, предсказуемое поведение под нагрузкой и никаких накладных расходов на запуск процессов. Реальный выигрыш по скорости и ресурсам дали два изменения в этой модели:

  • Исправили получение метаданных. Раньше pg_anon опрашивал каждую таблицу отдельным соединением. На небольшой базе это незаметно, но на схеме в 15 000 таблиц одна только подготовка занимала 5–10 минут — ещё до того, как начиналась сама выгрузка. Теперь метаданные всех колонок собираются одним запросом, и те же 15 000 таблиц готовятся за пару секунд. Это главный выигрыш на базах с большим числом таблиц;

  • Переработали компрессию. Раньше данные сначала копировались в промежуточный буфер, а сжимались отдельным шагом. Теперь чтение и сжатие идут одним потоком — данные проходят через gzip‑файловый объект на лету. Уровень сжатия при этом снижен с максимального до быстрого (gzip level 1): дампы стали чуть больше, зато компрессия перестала быть узким местом и меньше грузит CPU. Этот же потоковый подход убрал и утечку памяти — на базах в десятки гигабайт компрессия больше не разрастается в потреблении и не уходит под OOM‑killer, особенно на таблицах с минифицированными JSON‑строками.

Параллелизм при этом остаётся управляемым. Степень нагрузки на исходную базу настраивается опцией --db-connections — сколько параллельных подключений к базе открывать. Прежние --processes и --db-connections-per-process остались как устаревшие алиасы для обратной совместимости, но управляющая опция теперь одна.

Единой таблицы «до/после» не приводим, так как на разных базах выигрыш слишком разный. Правило простое: чем больше в базе таблиц, тем сильнее эффект от сбора метаданных одним запросом; чем крупнее сами таблицы, тем важнее подешевевшее сжатие. По CPU и памяти стало легче во всех случаях.

1.6. REST API и автоматизация

Если pg_anon запускается руками инженера, CLI обычно достаточно. Но в крупных командах маскирование часто становится частью CI/CD‑пайплайна или внутренней self‑service системы. Например, ночной пайплайн снимает свежий замаскированный срез прода и поднимает его на стенде аналитиков к утру. Снятие дампа и накатку запускает планировщик, без участия человека. В таких сценариях удобнее работать через HTTP API. Сервис поднимается командой pg_anon_api, которую мы уже упоминали выше.

Подтянули то, что важно для автоматизации. Ответы и вебхуки теперь несут стандартизованные коды ошибок, поэтому вызывающая сторона может разобрать причину сбоя машинно, а не парсить текст. А опция --internal-operation-id позволяет привязать запуск к своему идентификатору и потом отслеживать его в логах и колбэках. Кроме этого, можно пробросить свои заголовки и метаданные, а также управлять проверкой SSL, что упрощает интеграцию с внутренними сервисами. Также добавили возможность через API отслеживать, какие операции были запущены ранее, и читать логи этих операций. В разделе 2.10 разобраны запуск сервера и проведение операций.

На схеме ниже приведён пример автоматического пайплайна для накатки маскированных данных в аналитический контур, в удобное время:

2. Демонстрация на реалистичной БД

Дальше идёт сквозной прогон pg_anon по всем режимам на одной БД. Сначала init разворачивает в исходной базе функции маскирования. Затем create-dict сканирует исходную БД и собирает словарь: какие поля считать чувствительными и какое правило к ним применить. По этому словарю view-fields и view-data показывают, что попадёт под маску, а dump снимает уже замаскированный дамп, который restore заливает в целевую базу. Отдельная ветка sync-режимов разбивает дамп и восстановление на структуру и данные по отдельности — её разбираем в разделе 2.8.

Почти каждый шаг использует результаты предыдущих, но каждый раздел самодостаточен как объяснение, поэтому отдельный режим можно изучить сам по себе. Ниже — карта зависимостей режимов и артефактов:

Коротко про словари

Вся работа pg_anon вращается вокруг словарей. Если вы читали прошлую статью, давайте вспомним основные моменты. Если нет — этого будет достаточно, чтобы двигаться дальше.

  • Мета‑словарь — файл с правилами, по которым ищутся чувствительные данные (по именам полей, типам, регулярным выражениям по содержимому). Сканирование прогоняет эти правила по базе и по ним раскладывает поля на два других словаря: словарь чувствительных данных и словарь нечувствительных данных;

  • Словарь чувствительных данных — перечень чувствительных полей: для каждого задано, какой функцией его маскировать. Используется для дампа и для ускорения повторных сканирований;

  • Словарь нечувствительных данных — перечень нечувствительных полей, и ничего больше. Используется только для ускорения повторных сканирований;

  • Словарь таблиц — перечень таблиц. Используется только для частичного дампа и восстановления, в качестве чёрного или белого списка. Регулирует, какие таблицы брать в частичный дамп или восстанавливать из дампа.

Подробный разбор формата словарей есть в документации.

2.1. Демонстрационная БД

Для демонстрации мы собрали небольшую базу банка. Область выбрана не случайно — в ней естественно соседствуют явные персональные данные и техническая обвязка, на которой удобно показать маскирование.

В центре базы стоят клиенты с ФИО, паспортом, телефоном и почтой, их счета и карты, история операций, сотрудники и отделения. Именно эти поля: ФИО, паспорт, номер карты и контакты, дальше и попадут под маскирование.

Схема намеренно собрана так, чтобы на одной базе показать все сложные конструкции PostgreSQL из раздела 1.4, причём каждая из них применяется по своему прямому назначению:

  • транзакции разбиты на партиции по диапазону дат, с внешними ключами на партиционированную таблицу;

  • уведомления используют наследование через INHERITS, email и sms вынесены в дочерние таблицы;

  • доступный остаток по счёту и маскированный номер карты — вычисляемые колонки GENERATED ALWAYS;

  • идентификаторы таблиц — identity‑последовательности;

  • паспорт — составной тип, а счёт, ИНН, телефон, почта и номер карты — домены; эти вспомогательные объекты всплывут в частичном дампе;

  • среди объектов есть имена со спецсимволами и триггеры событий.

Полный SQL схемы с данными лежит здесь. Ниже показана её схема:

2.2. Подготовка стенда

Чтобы поднять стенд, нужно поставить pg_anon и развернуть демо‑базу в уже работающем экземпляре PostgreSQL. Если готового PostgreSQL под рукой нет, мы подготовили инструкцию по поднятию окружения в docker. Также все файлы, с которыми будем работать в рамках статьи, находятся в хранилище. Дальше всё показываем от имени суперпользователя postgres.

Важное примечание! Для работы pg_anon требуются клиентские утилиты Postgres — pg_dump и pg_restore. Правило по версиям простое: каждая утилита должна совпадать с версией своей базы — pg_dump с версией источника, pg_restore с версией приёмника. Сам приёмник при этом может быть той же мажорной версии, что и источник, или новее — дамп с PostgreSQL 15 корректно разворачивается в 17, но не наоборот. Если утилиты лежат в нестандартных местах, пути к нужным pg_dump и pg_restore задаются опциями --pg-dump, --pg-restore или через опцию --config (как показано в документации). В примерах статьи клиентские утилиты psql и createdb вызываются по имени — это работает, только если каталог с ними есть в PATH. Проверить наличие можно командой which psql (в Windows — where psql); если путь не находится, вызывайте утилиты по абсолютному пути.

Установка pg_anon

Ставим пакет сразу с группой зависимостей [api], потому что REST‑сервис понадобится в конце прогона, в разделе про API. После установки проверяем, что обе команды на месте:

pip install "pg_anon[api]==1.11.0"
pg_anon --version
pg_anon_api --version

Пароль — через файл, а не в команде

Дальше во всех вызовах pg_anon мы указываем не --db-user-password, а --db-passfile — путь к файлу с паролем. Так пароль не светится в списке процессов, в истории shell и в логах. Формат — тот же, что у .pgpass в PostgreSQL: строка на подключение, поля через двоеточие:

имя_хоста:порт:база:пользователь:пароль

Для нашего стенда хватит одной строки с масками * в поле базы — она покрывает и источник, и приёмник на любом порту. Создадим файл в рабочем каталоге и обязательно выставим права 600: файл с более широкими правами будет проигнорирован, и пароль из него не прочитается.

printf 'localhost:*:*:postgres:postgres\n' > pgpass.conf
chmod 600 pgpass.conf
export PGPASSFILE=$PWD/pgpass.conf

В вызовах pg_anon подставляем --db-passfile=pgpass.conf вместо --db-user-password, а переменная PGPASSFILE отдаёт тот же файл клиентским утилитам Postgres (psql, createdb), которые мы вызываем вручную при подготовке стенда.

Развёртывание демонстрационной БД

Создаём две базы. Первая, pg_anon_demo_source, содержит реальные данные. Вторая, pg_anon_demo_target, служит приёмником, куда позже restore зальёт уже замаскированный дамп. Схему и данные накатываем только в источник, приёмник пока остаётся пустым.

wget -O PG_ANON_DEMO_DB.sql "https://raw.githubusercontent.com/max-ibragimow/pg_anon_demo/master/bank/PG\_ANON\_DEMO\_DB.sql"
createdb -h localhost -p 5432 -U postgres pg_anon_demo_source
createdb -h localhost -p 5432 -U postgres pg_anon_demo_target
psql -h localhost -p 5432 -U postgres -d pg_anon_demo_source -f PG_ANON_DEMO_DB.sql

Про суперпользователя и права

В демонстрации мы для простоты везде работаем суперпользователем postgres. На практике на источнике он не нужен: pg_anon заводит схему anon_funcs (хватает права CREATE на базе, а pgcrypto — доверенное расширение; на версиях до PostgreSQL 13 его разово ставит суперпользователь), а дальше только читает данные и выполняет функции — SELECT на таблицах и EXECUTE на функциях. Этого достаточно, чтобы запускать его под ролью с ограниченными правами.

На приёмнике требования выше: часть объектов — например, событийные триггеры (в демо bank_ddl_audit) — в PostgreSQL создаёт только суперпользователь, поэтому полное восстановление такой схемы без него не обойдётся.

2.3. Инициализация в исходной базе — init

Конвейер начинается с инициализации (init). Этот режим создаёт в исходной базе схему anon_funcs с функциями маскирования, которые остальные режимы потом применяют для маскирования. Больше init ничего не делает — ваших данных и существующих объектов он не трогает. Но иметь в виду стоит: это всё же изменение в боевой базе — новая схема anon_funcs с функциями и расширение pgcrypto, — так что на проде шаг требует прав на создание объектов и обычно согласования. Инициализацию можно запускать повторно. Режим идемпотентен и просто переустанавливает функции, ничего не ломая при следующем прогоне на той же базе. Без этого шага другие режимы в исходной базе не запустятся, поэтому инициализация обязательна. Полный набор предустановленных функций описан в документации.

Режиму достаточно указать доступ к исходной базе:

pg_anon init
--db-host=localhost
--db-port=5432
--db-user=postgres
--db-passfile=pgpass.conf
--db-name=pg_anon_demo_source

После выполнения операции увидим, что схема anon_funcs с функциями маскирования была успешно добавлена:

Все опции подключения можно посмотреть через pg_anon init --help или в документации.

2.4. Создание словаря — create‑dict

Следующий режим — сканирование (create-dict). Он необязателен для запуска и нужен для исследования базы при настройке пайплайна маскирования. Режим изучает исходную базу по правилам мета‑словаря и раскладывает поля на два словаря. Чувствительность полей задаём мы сами, сканер не решает это за нас. Чувствительный словарь — это набор правил для конкретных полей: какие поля отнесли к чувствительным и каким SQL‑выражением маскировать каждое при выгрузке. Дальше по нему работают предпросмотр и dump. Нечувствительный собирает поля без таких данных, чтобы при следующих прогонах не сканировать их заново.

Сканирование не гарантирует, что найдёт все ПДн. Результат зависит и от правил мета‑словаря, и от того, какие данные реально лежат в таблицах. Задач у него две. Первая — первичный анализ базы. Сканер даёт черновой словарь, который потом дополняют, вместо того чтобы писать его с нуля. Вторая — проверка уже собранного словаря. Повторный прогон показывает, не осталось ли полей, которые в словарь не попали. Попутно скан подбирает функции маскирования под типы полей. На выходе получается заготовка. Подтвердить, что чувствительные данные покрыты, должен специалист.

Мета‑словарь для демо‑БД

Мета‑словарь мы пишем сами. Простые поля видно по имени, но реальная база на этом не заканчивается. В нашей схеме есть таблица обращений support_requests с колонкой message — свободный текст, где по имени колонки ничего не заподозришь, а внутри вперемешку с обычными фразами лежат ФИО, ИНН, номера карт, почта и телефоны. Именно на таких полях сканирование и оправдывает себя.

На схеме ниже можно увидеть, какие виды проверок есть в мета‑словаре и как они срабатывают:

Порядок проверок не случаен, это воронка. Сначала идут дешёвые проверки и фильтры, которые вообще не читают данные, проходят по всем полям сразу и отсеивают большую часть. Поле, по которому уже всё ясно, уходит в словарь и из воронки выбывает, а остальные спускаются ниже. И только в конце, на оставшейся узкой выборке, включаются тяжёлые проверки, которые реально читают данные таблиц. Смысл простой, не гонять дорогой анализ содержимого по всей базе, а доводить до него лишь то, что не отсеялось раньше.

Дальше разбираем сами виды проверок по порядку — от самых дешёвых к тем, что читают данные. Каждая проверка задаётся своим ключом в мета‑словаре; ключи сгруппированы по двум ступеням воронки.

Сначала — проверки, которые не читают данные

Они проходят по всем полям сразу и отсеивают большинство, ничего не вычитывая из таблиц — работают только с метаданными (имя и тип колонки).

1. По имени (field). Заранее известные и самые очевидные поля берём точными именами, а телефоны и почту регуляркой по имени.

"field": {
   "constants": [
       "full_name", "holder_name", "counterparty_name",
       "pan", "account_no", "passport", "birth_date",
       "inn", "message_text",
   ],
   "rules": [
       ".*phone.*",
       ".*email.*",
   ],
}

2. Фильтрация по типу (sens_pg_types). Ограничиваем, поля каких типов вообще стоит читать дальше. Чтение обходится дороже всего, поэтому оставляем только настоящий свободный текст и не трогаем числа, домены и идентификаторы.

"sens_pg_types": ["text"]

Затем — проверки, которые работают с данными

Всё, что не отсеялось выше, уходит на этап чтения. У этих правил префикс data_: они уже обращаются к содержимому таблиц. Начинается этап с того, что мы ограничиваем, какие строки вообще читать, а дальше три проверки эти строки вычитывают и классифицируют.

3. По срезу данных (data_sql_condition). Транзакции — партиционированная таблица на десятки тысяч строк, гонять по ней анализ целиком расточительно. ПДн контрагента имеет смысл искать только во внешних (swift) переводах, поэтому сужаем выборку SQL‑условием. Маска по имени таблицы покрывает и родителя, и все партиции.

"data_sql_condition": [
   {
       "schema": "bank",
       "table_mask": "^transactions",
       "sql_condition": "WHERE channel = 'swift'"
   },
]

4. По собственной SQL‑функции (data_func). Единственный механизм, где чувствительность определяет не шаблон, а произвольный код в базе. ИНН регуляркой не поймать. Десять цифр ещё не ИНН, нужна проверка контрольного разряда. Мы заранее завели в базе функцию is_valid_inn и ссылаемся на неё. Сканер прогоняет ею значения и находит ИНН даже в теле обращения.

"data_func": {
   "text": [
       {
           "scan_func": "bank.is_valid_inn",
           "anon_func": "anon_funcs.random_in(
               ARRAY['Спасибо за помощь, вопрос решён.', ...]
           )",
       },
   ],
}

В роли scan_func может быть любая функция в базе — хоть полнотекстовый поиск, хоть вызов внешнего сервиса распознавания ПДн (но тогда данные уходят наружу, и с этим стоит быть осторожнее). Контракт у таких функций фиксированный — значение поля и его координаты на вход, признак чувствительности на выход; подробности в документации.

5. По списку констант (data_const). Лёгкий способ пометить поле, если в его значениях встречаются известные слова‑триггеры документов.

"data_const": {
   "partial_constants": ["паспорт", "снилс", "водительск", "Код подтверждения"]
}

6. По регулярному выражению (data_regex). Ищем уже по содержимому. Шаблоны почты и номера карты находят данные внутри message и body.

"data_regex": {
   "rules": [
      r"""[A-Za-z0-9._-]+@[A-Za-z0-9-]+\.[A-Za-z]{2,}""", # email
      r"""\d{4}\s\d{4}\s\d{4}\s\d{4}"""                   # карта
   ]
}

Чем маскировать — funcs

Отдельный блок funcs задаёт функцию маскиования по типу поля. Форматные ПДн вынесены в домены, и каждый маскируется под свой формат: почта остаётся почтой, телефон — телефоном, карта — картой. Строки различаются по длине: ФИО и держатель карты получают правдоподобную замену из отдельного пула значений, свободный текст — нейтральную подмену. Составной паспорт и домен счёта маскируются своими функциями — дефолтный хеш сломал бы их тип и CHECK. Ниже — характерные ключи, полный блок в файле словаря:

"funcs": {
   "bank.phone": "anon_funcs.random_phone('+79')",
   "bank.email": 'anon_funcs.partial_email("%s")',
   "date": '''anon_funcs.dnoise("%s"::timestamp, interval '1 month')''',
   "bank.passport": '''CASE WHEN "%s" IS NULL THEN NULL ELSE bank.fake_passport() END''',
   "character varying(120)": "bank.fake_full_name()",
   "text": "anon_funcs.random_in(ARRAY['Спасибо за помощь, вопрос решён.', ...])",
   "default": "anon_funcs.digest(\"%s\", '', 'sha256')",
   # остальные ключи — в bank_meta_dict.py
}

Функции здесь двух видов. Одни работают с исходным значением поля (partial_email, partial, dnoise) — у них в шаблоне стоит %s, имя маскируемого поля, чтобы функция получила его значения. Другие генерируют замену с нуля (random_phone, fake_full_name, fake_passport) — им %s не нужен. У генеративных есть нюанс: они не смотрят на вход и подставили бы значение даже туда, где его не было — у физлиц нет ИНН, у юрлиц паспорта. Чтобы копия не обзаводилась данными, которых в источнике нет, для таких полей маску заворачиваем в CASE: пустое оставляем пустым, остальное маскируем. Преобразующим это не нужно — NULL они пропускают сами.

Ключ default — запасной вариант для типа, не совпавшего с ключами выше. Для простоты ставим digest — стабильный хеш: одинаковые значения дают одинаковый результат, поэтому связи по полю сохраняются. Но по той же причине (детерминированность и открытая соль) такой хеш несложно подобрать, так что в реальной работе соль держат в секрете или берут недетерминированную функцию.

Демонстрационные функции bank.* содержатся в файле PG_ANON_DEMO_DB.sql и не поставляются с pg_anon из коробки. Итоговый мета‑словарь целиком лежит в файле bank_meta_dict.py.

Запуск сканирования

Сканируем исходную базу, на вход подаём мета‑словарь, на выход указываем пути для двух словарей:

pg_anon create-dict
--db-host=localhost --db-port=5432 --db-user=postgres
--db-passfile=pgpass.conf
--db-name=pg_anon_demo_source
--meta-dict-file=bank_meta_dict.py
--output-sens-dict-file=bank_sens_dict.py --output-no-sens-dict-file=bank_no_sens_dict.py

По умолчанию при сканировании данных читается не вся таблица, а выборка в 10 000 строк. Этого обычно хватает, чтобы определить характер данных и не сильно растягивать обработку. Но если в базе есть необязательные поля с чувствительными данными, может произойти ситуация, что в первые 10 000 строк ничего не попадёт и проверки не сработают. Для таких случаев опцией --scan-partial-rows можно регулировать объём выборки данных из таблиц для сканирования, а опцией --scan-mode=full задать режим чтения таблиц целиком.

Лог сканирования сокращённо:
============> Started pg_anon (v 1.11.0) in mode: create-dict
Target DB version: 15.18
Using 4 concurrent connections
Progress 0.0%
Progress 53.33%
Progress 93.33%
<============ Finished create-dict, result_code = done, elapsed: 1.14 sec

Для диагностики и отладки может потребоваться более подробный лог — он включается опцией -‑debug. Обращаться с ним стоит аккуратно: отладочный лог печатает реальные значения полей, поэтому в него попадают и персональные данные — это видно и в примере ниже. Поэтому такие логи держат в доверенном контуре и не прикладывают к задачам или переписке как есть. Привожу фрагмент, на котором видно, как сканируются отдельные поля и на каком правиле определяется чувствительность каждого:

Фрагмент отладочного лога сканирования:
Field bank.clients.full_name is SENSITIVE by rule "full_name"
Field bank.clients.email is SENSITIVE by rule "re.compile('.*email.*')"
...
Started check sensitive data in field - bank.cards.masked_pan. (6312 values)
checking by functions data of field bank.cards.masked_pan (type = text)
No one data functions found sensitive data in field bank.cards.masked_pan
checking by partial constants data of field bank.cards.masked_pan
No one partial constants matched in data of field bank.cards.masked_pan
checking by regexp data of field bank.cards.masked_pan
No one regexp rules found sensitive data in field bank.cards.masked_pan
Finished check sensitive data in field bank.cards.masked_pan - is INSENSITIVE
...
Started check sensitive data in field - bank.support_requests.message. (4521 values)
checking by functions data of field bank.support_requests.message (type = text)
Field bank.support_requests.message is SENSITIVE by data scan func bank.is_valid_inn
Finished check sensitive data in field bank.support_requests.message - is SENSITIVE
...
Field bank.email_notifications.body is SENSITIVE by data_regex (regex=re.compile('\\d{4}\\s\\d{4}\\s\\d{4}\\s\\d{4}', re.DOTALL); value=Уважаемый клиент Кузнецов Сергей Михайлович, операция по карте 2858 2832 1469 9210 выполнена.)

Что на выходе

Получаем два словаря — чувствительный и нечувствительный.

Сканер разложил функции маскирования, опираясь на тип полей. Для транзакций правила уточним под свои данные. В полях purpose и counterparty_name персональные данные есть только у внешних (swift) переводов — у внутренних (internal) операций это магазин и «Оплата покупки», трогать их не нужно. Тип поля такого не различает. Правило «маскировать, только когда channel = ‘swift’» — это условие на строку, а не на тип. Поэтому в словаре чувствительных данных для транзакций и их партиций правила дописываем вручную, выражением с CASE:

"counterparty_name": '''
    CASE WHEN "channel" = 'swift'
    THEN bank.fake_full_name() 
    ELSE "counterparty_name" END''',
"purpose": '''
    CASE WHEN "channel" = 'swift'
    THEN 'Перевод в пользу ' || bank.fake_full_name() 
   || ', ИНН ' || bank.fake_inn()
    ELSE "purpose" END''',

С этой доводкой словарь чувствительных данных готов. Итоговые файлы: чувствительный словарь — bank_sens_dict.py, нечувствительный словарь — bank_no_sens_dict.py

2.5. Предпросмотр — view‑fields и view‑data

Словарь чувствительных данных готов, но прежде чем гнать полный дамп, хочется глазами убедиться, что маскируется именно то, что нужно, и тем, чем нужно. Для этого есть два режима только на чтение, они ничего не меняют в базе, а лишь показывают, что произойдёт. view-fields отвечает на вопрос «какие поля и какой функцией», а view-data показывает, как это будет выглядеть в данных.

view‑fields — карта маскирования

Режим сверяет схему исходной базы с переданными словарями и выводит таблицу. Для каждого поля видно его тип, попало ли оно в словарь и каким правилом будет замаскировано. По сути это полная карта того, что маскируем, чем и где оставляем как есть.

pg_anon view-fields
--db-host=localhost
--db-port=5432
--db-user=postgres
--db-passfile=pgpass.conf
--db-name=pg_anon_demo_source
--prepared-sens-dict-file=bank_sens_dict.py

Режим печатает по строке на каждое поле каждой таблицы, включая партиции. Для демо‑базы это 89 строк. Целиком они не нужны, поэтому ниже сжатая выборка: не все поля и не все таблицы, а по одному‑два показательных поля на разные случаи. Две крайние колонки в ней постоянны: схема всегда bank, словарь всегда bank_sens_dict.py, но в реальной базе схем бывает несколько, а словарей на вход можно подать много, и именно по ним видно, к какой схеме относится поле и из какого файла сработало правило.

schema

table

field

type

dict_file_name

rule

bank

accounts

account_no

bank.account_no

bank_sens_dict.py

bank.mask_account_no(“account_no”)

bank

accounts

available_balance

numeric(18,2)

‑‑

‑‑

bank

accounts

currency

bank.currency

‑‑

‑‑

bank

branches

БИК‑код

character(9)

‑‑

‑‑

bank

clients

birth_date

date

bank_sens_dict.py

anon_funcs.dnoise(“birth_date”::timestamp, interval '1 month')

bank

clients

full_name

character varying(120)

bank_sens_dict.py

bank.fake_full_name()

bank

clients

passport

bank.passport

bank_sens_dict.py

CASE WHEN “passport” IS NULL THEN NULL ELSE bank.fake_passport() END

bank

support_requests

message

text

bank_sens_dict.py

anon_funcs.random_in(ARRAY['Спасибо за помощь, вопрос решён.', …])

bank

transactions

counterparty_name

character varying(120)

bank_sens_dict.py

CASE WHEN “channel” = 'swift' THEN bank.fake_full_name() ELSE “counterparty_name” END

bank

transactions_default

purpose

text

‑‑

‑‑

bank

transactions_2026_01

purpose

text

bank_sens_dict.py

CASE WHEN “channel” = 'swift' THEN 'Перевод в пользу ' || bank.fake_full_name() || ', ИНН ' || bank.fake_inn() ELSE “purpose” END

Прочерк (‑‑) в двух последних колонках значит, что поле остаётся нетронутым. Отсюда сразу видно несколько важных вещей:

  • Кастомные типы, домены и перечисления на месте. bank.account_no (домен) и bank.passport (составной тип) маскируются своими функциями, а не дефолтным хешем, который сломал бы их формат и CHECK. В то же время bank.currency (перечисление) персональных данных не содержит и осталось без маски.

  • Под каждый случай своё правило. ФИО уходит в правдоподобную замену (bank.fake_full_name), дата рождения — в dnoise со сдвигом на месяц. Свободный текст support_requests.message точечно не разобрать, поэтому он целиком заменяется нейтральной фразой (random_in).

  • Часть правил — с условием. У passport условие IS NULL не подставляет фейк туда, где паспорта не было. У counterparty_name и purpose условие channel = 'swift' маскирует только внешние переводы, а internal (магазины, «Оплата покупки») оставляет как есть.

  • Нечувствительное осталось нетронутым. Поле с кириллицей в имени (БИК‑код) распозналось и осталось без маски; вычисляемая available_balance — тоже.

  • Партиции видны по отдельности. pg_anon перечисляет и саму партиционированную таблицу, и каждую её секцию как отдельные таблицы — каждая идёт своими строками, а не сворачивается в одну.

  • Автоскан находит не всё, и на партициях это критично. Поле purpose замаскировано в transactions_2026_01, но в transactions_default — нет: сканер читает лишь выборку строк и ПДн в данных этой секции не нашёл. Опаснее другое: transactions партиционирована по датам, а такие таблицы ротируются — со временем заводятся новые секции (transactions_2026_04 и так далее). На момент сборки словаря их ещё нет, поэтому правило на них не навесится, и данные уедут в дамп как есть. Поэтому чувствительные колонки партиционированных таблиц нельзя оставлять на откуп скану данных по секциям — правило задают по имени на родителя и все секции, а словарь пересобирают/сверяют перед каждым дампом. Предпросмотр ловит уже пропущенное, но не защищает от секций, созданных после его прогона.

Саму карту можно подстроить под задачу. --view-only-sensitive-fields оставит только поля под маской, --schema-name/--table-name ограничат область, --json отдаст результат машиночитаемо.

view‑data — предпросмотр строк

view-fields показывает правила, но не данные. view-data идёт дальше. Он вытаскивает реальные строки таблицы, применяет к ним маску на лету, ровно так, как это сделает режим dump. Чтобы контраст был нагляднее, сначала посмотрим на исходные данные. Для этого подадим пустой словарь empty_dict.py. Маскировать нечего, и режим просто показывает таблицу как есть. Для удобства просмотра часть колонок из вывода убрана — в реальном выводе таблица показывается целиком, со всеми полями.

pg_anon view-data
--db-host=localhost
--db-port=5432
--db-user=postgres
--db-passfile=pgpass.conf
--db-name=pg_anon_demo_source
--prepared-sens-dict-file=empty_dict.py
--schema-name=bank \
--table-name=clients \
--limit=2

id

client_type

full_name

inn

passport

15 437

individual

Петров Сергей Петрович

None

<Record series='8949' number='896507'>

7654

corporate

ООО «Вектор»

8 181 599 170

None

Все значения в демонстрации синтетические. Выборки в разных разделах берут разные строки таблицы, а маскирование при каждом прогоне выдаёт новые случайные значения — поэтому конкретные имена и цифры у вас будут отличаться от показанных здесь, это нормально.

А теперь тот же запрос с боевым словарём:

pg_anon view-data
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_source \
--prepared-sens-dict-file=bank_sens_dict.py \
--schema-name=bank \
--table-name=clients \
--limit=2

id

client_type

* full_name

* inn

* passport

15 437

individual

Егоров Григорий Егорович

None

<Record series='8579' number='567001'>

7654

corporate

Комаров Тимофей Кириллович

6 586 595 772

None

Под маску ушли ФИО, ИНН и паспорт — их помечает звёздочка у имени колонки. Нечувствительное осталось нетронутым: id и client_type в обеих выборках совпадают, значит меняем только то, что в словаре.

Маска применяется ко всему полю целиком, но пустое остаётся пустым: у физлица нет ИНН, у юрлица — паспорта, и после маскирования эти ячейки так и остались None. Генеративные функции fake_inn и fake_passport не смотрят на вход и иначе подставили бы значение даже туда, где его не было, — поэтому в словаре мы обернули их в CASE … IS NULL.

Ещё одна деталь: у юрлица ООО «Вектор» в full_name встало ФИО. Поле хранит и людей, и организации, а маска на нём одна — fake_full_name — и что внутри, не различает. Оставили так намеренно: это случай, когда правило нужно подгонять под бизнес‑смысл данных, а не только под тип поля. К этому предлагаем вернуться позже в практической части.

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

Предпросмотр — дешёвая страховка. Полный дамп большой базы может идти очень долго, и обнаружить ошибку конфигурации уже в готовом замаскированном дампе обидно вдвойне. Два режима ловят заранее обе типичные беды. Первая, пропущенное чувствительное поле, тогда ПДн утекут в дамп. Вторая, лишняя или неверная маскировка, от которой ломаются связи или формат значений. Сверили глазами карту полей и пару выборок — и можно с уверенностью запускать dump.

2.6. Полный дамп — dump

Когда убедились, что все словари готовы, можно запускать режим dump. Он выгружает базу целиком, но по дороге подменяет данные из чувствительных полей функциями из словаря. Исходная база при этом не меняется. Маскирование происходит на лету, прямо при чтении, поэтому в файлы дампа попадают уже замаскированные значения, а персональные данные физически не покидают исходный кластер. Но «замаскировано» — это ещё не «обезличенно». Прямые идентификаторы (ФИО, паспорт, номер карты) маскирование убирает, а вот связи между записями и квази‑идентификаторы — поля, которые сами по себе человека не называют, но в совокупности могут на него указать: даты и суммы операций, идентификаторы клиента и счёта — дамп сохраняет как есть. Насколько с таким набором дамп можно выносить за пределы контура, зависит от полноты словаря; подробно разберём это при сравнении источника и приёмника в разделе 2.7.

Ниже отображена схема работы режима dump:

Создадим замаскированный дамп, применяя ранее созданный словарь:

pg_anon dump \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_source \
--prepared-sens-dict-file=bank_sens_dict.py \
--output-dir=bank_dump

В этой команде опция --prepared-sens-dict-file задаёт тот самый словарь, который мы собрали и проверили, --output-dir указывает, куда сложить дамп. Папка должна быть пустой, так как стоит проверка от случайной перезаписи. Можно включить авто‑очистку папки через опцию --clear-output-dir. Структуру БД (pre‑data, post‑data) выгружает именно pg_dump, и его версия должна совпадать с исходной базой.

Сокращённый лог дампа:
============> Started pg_anon (v 1.11.0) in mode: dump
Target DB version: 15.18
pg_dump path: /usr/bin/pg_dump
Postgres utils exists checking
-------------> Started dump
-------------> Started dump pre-data (pg_dump)
pg_dump: чтение схем
pg_dump: чтение пользовательских таблиц
…
<------------- Finished dump pre-data (pg_dump)
-------------> Started dump post-data (pg_dump)
…
<------------- Finished dump post-data (pg_dump)
-------------> Started dump data
…
SELECT "id" as "id",
"client_type" as "client_type",
bank.fake_full_name()::"pg_catalog"."varchar" as "full_name",
anon_funcs.partial_email("email")::"pg_catalog"."varchar" as "email",
anon_funcs.random_phone('+79')::"pg_catalog"."varchar" as "phone",
CASE WHEN "inn" IS NULL THEN NULL ELSE bank.fake_inn() END::"pg_catalog"."varchar" as "inn",
CASE WHEN "passport" IS NULL THEN NULL ELSE bank.fake_passport() END::"bank"."passport" as "passport",
anon_funcs.dnoise("birth_date"::timestamp, interval '1 month')::"pg_catalog"."date" as "birth_date",
"created_at" as "created_at"
FROM "bank"."clients"
…
SELECT "id" as "id",
"account_id" as "account_id",
anon_funcs.partial("pan", 6, '000000', 4)::"pg_catalog"."varchar" as "pan",
bank.fake_holder_name()::"pg_catalog"."varchar" as "holder_name",
"expires_at" as "expires_at",
"status" as "status"
FROM "bank"."cards"
SELECT "id" as "id",
"account_id" as "account_id",
"booked_at" as "booked_at",
"amount" as "amount",
"direction" as "direction",
"channel" as "channel",
CASE WHEN "channel" = 'swift' THEN bank.fake_full_name() ELSE "counterparty_name" END::"pg_catalog"."varchar" as "counterparty_name",
CASE WHEN "channel" = 'swift' THEN 'Перевод в пользу ' || bank.fake_full_name() || ', ИНН ' || bank.fake_inn() ELSE "purpose" END::"pg_catalog"."text" as "purpose"
FROM "bank"."transactions_2026_01"
…
SELECT "id" as "id",
"account_id" as "account_id",
"booked_at" as "booked_at",
"amount" as "amount",
"direction" as "direction",
"channel" as "channel",
CASE WHEN "channel" = 'swift' THEN bank.fake_full_name() ELSE "counterparty_name" END::"pg_catalog"."varchar" as "counterparty_name",
"purpose" as "purpose"
FROM "bank"."transactions_default"
SELECT "id" as "id",
"client_id" as "client_id",
"channel" as "channel",
anon_funcs.random_in(ARRAY['Спасибо за помощь, вопрос решён.', 'Как изменить лимит по карте в приложении?', 'Подскажите график работы отделения в выходные.', 'Хочу уточнить статус моей заявки.'])::"pg_catalog"."text" as "message",
"created_at" as "created_at"
FROM "bank"."support_requests"
Using 4 concurrent connections
Progress 0.0%
================> Task [a447791f-644f-413a-87ca-739c1fd339ed] Started task SELECT … FROM "bank"."clients" to file /tmp/pg_anon/bank_dump/21f72b349cfbe2bd41531d3ed7a020cf.bin.gz
…
<================ Task [a447791f-644f-413a-87ca-739c1fd339ed] Finished task SELECT … FROM "bank"."clients"
Progress 71.43%
…
<------------- Finished dump data
<------------- Finished dump
<============ Finished pg_anon in mode: dump, result_code = done, elapsed: 0.57 sec

Как устроен дамп

Весь лог можно поделить на следующие этапы:

  1. Информация об окружении. Версии pg_anon, версия СУБД, путь к утилите pg_dump;

  2. Дамп секции pre‑data через pg_dump;

  3. Дамп секции post‑data через pg_dump;

  4. Демонстрация запросов, которые будут выполняться для копирования данных;

  5. Запуск задачи по копированию и сжатию данных отдельно взятой таблицы;

  6. Завершение задачи по копированию и сжатию данных отдельно взятой таблицы;

  7. Завершение процесса. Тут сразу указывается, как долго шла операция и завершилась ли она корректно.

Для более подробного анализа действий можно включить отладочный лог через опцию --debug.

Как мы заметили выше по логу, dump работает в три приёма. Сначала pg_dump снимает структуру, то есть схемы, типы, таблицы, функции и триггеры. Логически она делится на две части. В pre‑data попадает всё, что нужно до загрузки строк, а в post‑data попадают индексы, ограничения и триггеры, которые навешиваются уже поверх данных. После этого отдельной фазой выгружаются сами данные, причём каждая таблица читается своим запросом.

Именно на фазе данных и происходит маскирование. Для каждой таблицы pg_anon строит запрос SELECT, в котором поля без чувствительных данных читаются как есть, а с чувствительными данными оборачиваются в функцию маскирования из словаря — это и видно в логе выше. Стоит обратить внимание на несколько деталей:

  • Тип колонки сохраняется. Замаскированное значение приводится обратно к типу поля: fake_full_name — к varchar, составной паспорт — к bank.passport. Поэтому на выходе тип данных не меняется и дамп остаётся загружаемым;

  • Условная логика уходит прямо в SELECT. Там, где маска зависит от самой строки, в запрос попадает CASE: у inn и passport — проверка IS NULL (пустое остаётся пустым), у counterparty_name и purpose — channel = 'swift' (маскируем только внешние переводы);

  • Партиции выгружаются по отдельности. Партиционированная transactions читается своими запросами по каждой секции (transactions_2026_01, …, transactions_default);

  • Вычисляемые колонки не выгружаются. Полей GENERATED ALWAYS … STORED (например available_balance и masked_pan) в SELECT нет специально — их значения не переносятся, а пересчитываются на приёмнике из своих формул.

Данные выгружаются в несколько потоков, в логе это строка Using 4 concurrent connections, так что таблицы пишутся параллельно. На демонстрационной базе весь дамп уложился меньше чем в секунду. Управлять параллелизмом дампа можно с помощью опцией --db-connections. Перед распараллеливанием, открывается общая транзакция и создаётся снапшот, чтобы все параллельные операции работали с консистентным набором данных.

Стоит помнить о нагрузке на источник. Функции маскирования выполняются на самой исходной базе при чтении, поэтому во время дампа тратят её CPU. И дамп идёт в общей транзакции со снапшотом: долгая читающая транзакция удерживает горизонт очистки, так что autovacuum на это время не вычистит мёртвые строки — на большой активно пишущей базе таблицы за время дампа немного распухнут. На небольших базах это незаметно, а на нагруженном проде долгий дамп лучше ставить в окно потише или снимать дамп с реплики.

Важно. В SELECT можно подставить что угодно, лишь бы подходило в запрос: константу, конкатенацию, функцию. Но если результат не влезет в тип или ограничение колонки, дамп пройдёт, а восстановление упадёт. Классический случай — функция вернула строку в 64 символа для поля на 10. За целостность данных отвечает инженер, который настраивает маскирование.

Также коротко упомяну возможность фильтрации строк в отдельных таблицах на этапе дампа. Если в правиле конкретной таблицы в словаре чувствительных данных указать sql_condition (схема словаря), то pg_anon подставит его как WHERE и выгрузит только подходящие строки. Так из таблицы можно забрать не всё, а, например, данные за нужный период. На этой возможности построен сценарий регулярной подкатки данных из раздела 2.8.

Коротко об отладке

Чтобы минимизировать затраты на отладку процесса маскирования, в режиме dump есть три отладочных опции для проверочных прогонов разной глубины:

  • --dbg-stage-1-validate-dict — проверяет словарь. Он показывает таблицы и прогоняет запросы маскирования, но никуда не выгружает данные, поэтому годится для быстрой проверки, что словарь валиден и SQL собирается;

  • --dbg-stage-2-validate-data — идёт дальше и выгружает только данные с маскированием, без структуры, чтобы убедиться, что результат реально пишется и типы сходятся. Такой дамп можно накатить на БД, в которой уже подготовлена необходимая структура;

  • --dbg-stage-3-validate-full — прогоняет весь пайплайн целиком, но на небольшой выборке строк и без накатки секции post‑data. Это даёт полную проверку логики без выгрузки всей базы. Такой дамп можно накатить на пустую БД.

Подробно отладку с этими стадиями мы разберём в одной из следующих статей. Пока достаточно знать, что они есть, чем отличаются и как с их помощью можно ускорить отладку процесса маскирования на огромных базах, не затрачивая много времени на дамп.

Что на выходе

В папке bank_dump после прогона лежит готовый дамп.

Структуру базы хранят pre_data.backup и post_data.backup в формате custom от pg_dump. Маскированные данные складываются в файлы bin.gz, по одному сжатому файлу на таблицу. В metadata.json записаны служебные сведения, среди них версия Postgres, хеш словаря, значения последовательностей, отношения файлов к таблицам и количество выгруженных строк по каждой таблице. Список выгруженных таблиц лежит в dumped_tables.py.

Этот каталог самодостаточен. Его и принимает на вход restore, чтобы развернуть замаскированную копию на приёмнике.

2.7. Полное восстановление — restore

Режим restore разворачивает замаскированный дамп в целевой базе. Он накатывает структуру, заливает уже маскированные данные и навешивает индексы, ограничения и триггеры. Повторно ничего не обрабатывается — данные замаскированы ещё на этапе dump, а restore просто загружает их как есть. Этот режим работает только с дампами, сделанными через pg_anon dump. Ниже отображена схема работы режима restore:

Выполним восстановление замаскированного дампа:

pg_anon restore \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_target \
--input-dir=bank_dump

Теперь в параметры подключения указываем приёмник, который создали в разделе 2.2. В опцию --input-dir указываем, откуда взять дамп. Версия pg_restore при этом должна совпадать с приёмником, а сам он быть не старее источника. Целевая БД должна быть пустой, так как стоит проверка от случайной перезаписи. Это особенно актуально, если после дампа забыть поменять параметры подключения с исходной БД на целевую. Поведение режима можно менять с помощью других опций, таких как --drop-db и --ignore-privileges. О них подробнее в документации.

Сокращённый лог восстановления:
============> Started pg_anon (v 1.11.0) in mode: restore
Target DB version: 15.18
pg_restore path: /usr/bin/pg_restore
Postgres utils exists checking
-------------> Started restore
-------------> Started restore pre-data (pg_restore)
pg_restore: connecting to database for restore
pg_restore: creating SCHEMA "anon_funcs"
pg_restore: creating SCHEMA "bank"
pg_restore: creating COMMENT "EXTENSION pgcrypto"
pg_restore: creating DOMAIN "bank.account_no"
pg_restore: creating TYPE "bank.currency"
pg_restore: creating DOMAIN "bank.email"
pg_restore: creating DOMAIN "bank.inn"
pg_restore: creating TYPE "bank.passport"
…
pg_restore: creating FUNCTION "anon_funcs.partial_email(text)"
pg_restore: creating FUNCTION "bank.fake_full_name()"
…
pg_restore: creating TABLE "bank.clients"
pg_restore: creating SEQUENCE "bank.clients_id_seq"
…
pg_restore: creating TABLE "bank.transactions"
pg_restore: creating TABLE "bank.transactions_2026_01"
…
pg_restore: creating TABLE ATTACH "bank.transactions_2026_01"
…
<------------- Finished restore pre-data (pg_restore)
>>>>>>>>>>>>>>>>>>>> Started task copy_to_table bank.accounts
>>>>>>>>>>>>>>>>>>>> Started task copy_to_table bank.clients
…
>>>>>>>>>>>>>>>>>>>> Finished task bank.clients
…
-------------> Started restore post-data (pg_restore)
pg_restore: creating CONSTRAINT "bank.clients clients_pkey"
pg_restore: creating INDEX "bank.idx_transactions_account"
pg_restore: creating CONSTRAINT "bank.transactions transactions_pkey"
…
pg_restore: creating INDEX ATTACH "bank.transactions_2026_01_pkey"
…
pg_restore: creating FK CONSTRAINT "bank.transactions transactions_account_id_fkey"
…
pg_restore: creating EVENT TRIGGER "bank_ddl_audit"
<------------- Finished restore post-data (pg_restore)
SELECT setval(quote_ident('bank') || '.' || quote_ident('accounts_id_seq'), 20000 + 1);
SELECT setval(quote_ident('bank') || '.' || quote_ident('notifications_id_seq'), 40000 + 1);
…
-------------> Started analyze
================> Started query analyze "bank"."clients"
<================ Finished query analyze "bank"."clients"
…
<------------- Finished analyze
<------------- Finished restore
<============ Finished pg_anon in mode: restore, result_code = done, elapsed: 1.11 sec

Как устроено восстановление

Весь лог можно поделить на следующие этапы:

  1. Информация об окружении. Версии pg_anon, версия СУБД, путь к утилите pg_restore;

  2. Накатка расширений и секции pre‑data через pg_restore;

  3. Заливка данных;

  4. Накатка секции post‑data через pg_restore;

  5. Установка значений для всех последовательностей;

  6. Прогон всех таблиц через анализ и проверка количества восстановленных строк;

  7. Завершение процесса. Тут сразу указывается, как долго шла операция и завершилась ли она корректно.

Для более подробного анализа действий можно включить отладочный лог через опцию --debug. Тут важно отметить, что если что‑то не удастся восстановить или количество залитых строк данных не будет соответствовать количеству строк в дампе, тогда операция завершится со статусом «ошибка».

В этом режиме pg_anon сам выстраивает зависимости в правильном порядке. Сначала создаются расширения, типы, домены и функции, затем таблицы и последовательности, к родителям подключаются партиции, и уже после заливки данных навешиваются ключи, индексы и событийный триггер. Так все сложные конструкции из раздела 1.4 — составной тип паспорта, домены форматных ПДн (счёт, ИНН, телефон, почта, номер карты), партиционированные транзакции с внешними ключами и identity‑последовательности — поднимаются на пустой базе одним прогоном restore. Не нужно заранее создавать типы, домены и функции или потом дособирать внешние ключи руками. В конце pg_anon выставляет значения последовательностей, чтобы в приёмник можно было продолжать вставлять строки без конфликтов по ключам, и прогоняет ANALYZE, чтобы у новой базы сразу была свежая статистика для планировщика.

Сравнение источника и приёмника

Главный итог проверяется глазами. Структура приёмника повторяет источник один в один, а в чувствительных полях вместо реальных ПДн лежит синтетика. Нечувствительные данные при этом совпадают с источником.

То, что структура цела, по сути уже доказал лог восстановления выше. Осталось сравнить данные. Возьмём одну и ту же выборку на источнике и приёмнике:

# источник, реальные данные
psql -h localhost -p 5432 -U postgres -d pg_anon_demo_source -c "SELECT id, client_type, full_name, inn, passport FROM bank.clients ORDER BY id LIMIT 5;"
# приёмник, после restore
psql -h localhost -p 5432 -U postgres -d pg_anon_demo_target -c "SELECT id, client_type, full_name, inn, passport FROM bank.clients ORDER BY id LIMIT 5;"

Получим наглядный результат для сравнения:

В приёмнике full_name, inn и passport заменены на синтетические значения, а id и client_type совпадают с источником, потому что это не чувствительные поля. Получили рабочую копию базы той же формы, но с замаскированными данными в чувствительных полях.

Что осталось неизменным

Замаскированы значения в чувствительных полях, всё остальное перенесено один в один, а именно идентификаторы клиентов и счетов, суммы, даты, каналы и направления операций. Такие поля называют квази‑идентификаторами, и по ним записи источника и приёмника сопоставляются напрямую. В нашей выборке клиент с id 2 остаётся клиентом с id 2, а его операция на ту же сумму и в ту же дату находится в приёмнике поиском по двум полям.

Для dev/test‑контура это ровно то, что нужно, потому что аналитику и разработчику важны рабочие связи и правдоподобные распределения. Но копия при этом псевдонимизирована, а не обезличена. Если она уходит за пределы контролируемого контура, квази‑идентификаторы тоже нужно трогать, например огрублять даты до месяца и округлять суммы. Такие правила задаются в том же словаре обычными SQL‑выражениями, отдельный механизм для этого не требуется.

Таким образом, мы прошли полный цикл маскирования от составления мета‑словаря до сверки маскированных данных на приёмнике. Дальше разберём режимы, которые нужны не в каждом проекте. Это раздельная выгрузка структуры и данных, частичный дамп и восстановление по спискам таблиц, а также работа через REST API.

2.8. Раздельный дамп структуры и данных — sync‑struct / sync‑data

У pg_anon, помимо полного dump и restore, есть режимы, которые работают только с одной половиной базы. sync-struct отвечает за структуру, sync-data за маскированные данные. У каждого своя пара выгрузки и восстановления, sync-struct-dump с sync-struct-restore и sync-data-dump с sync-data-restore.

Полный dump и restore закрывают большинство задач, и даже из обычного полного дампа можно восстановить отдельно структуру и отдельно данные. Поэтому делить выгрузку на половины ради самого деления смысла нет. Раздельные режимы нужны под конкретные операционные сценарии, где раз за разом требуется ровно одна половина.

Например, нам надо регулярно разворачивать аналитический контур с замаскированными данными. Структуру достаточно положить один раз, а данные хочется обновлять регулярно. Один раз поднимаем на приёмнике схему через sync-struct, не делая при этом полноценного долгого дампа, и контур готов принимать данные. Дальше по расписанию, скажем раз в неделю, sync-data-dump выкачивает свежие маскированные строки за нужный период, а sync-data-restore доливает их в контур. Ограничить выборку по датам помогает то самое условие sql_condition в словаре чувствительных данных, про которое мы говорили в разделе 2.6. Кроме того, стендов может быть несколько, и каждому несложно лить свою выборку данных, ограничив её своим sql_condition. Обе половины кладёт один и тот же pg_anon с одной конфигурацией, поэтому структура и данные гарантированно совместимы. В итоге аналитики всё время видят актуальные маскированные данные.

Каждый режим восстановления разборчив к переданному дампу. sync-struct-restore ждёт, что в дампе есть структура, а sync-data-restore ждёт, что есть данные. Поэтому оба могут принять и полный дамп, но восстановят только нужную часть. Подробнее о комбинациях режимов можно посмотреть в документации.

Дальше покажем базовый сценарий. Сначала переносим структуру, потом отдельно заливаем данные.

sync-struct-dump снимает только схему, без строк:

pg_anon sync-struct-dump \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_source \
--prepared-sens-dict-file=bank_sens_dict.py \
--output-dir=bank_dump__struct

Если посмотрим в папку с таким дампом, то обнаружим только файлы metadata.json, post_data.backup, pre_data.backup.

Приёмник, как и при полном восстановлении, должен быть пустым, поэтому пересоздадим его:

dropdb -h localhost -p 5432 -U postgres pg_anon_demo_target
createdb -h localhost -p 5432 -U postgres pg_anon_demo_target

sync-struct-restore разворачивает её на пустом приёмнике:

pg_anon sync-struct-restore \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_target \
--input-dir=bank_dump__struct

Убедимся, что схема БД целиком залилась, при этом данных внутри таблиц нет:

sync-data-dump собирает только маскированные данные, без структуры:

pg_anon sync-data-dump \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_source \
--prepared-sens-dict-file=bank_sens_dict.py \
--output-dir=bank_dump__data

В этом дампе мы уже увидим файлы metadata.json, dumped_tables.py и файлы данных в формате bin.gz. При этом здесь нет файлов post_data.backup и pre_data.backup.

sync-data-restore заливает их в уже готовую схему:

pg_anon sync-data-restore \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_target \
--input-dir=bank_dump__data

Проверяем, что данные успешно добавлены:

Итог тот же, что у полного цикла из разделов 2.6 и 2.7, только собранный из двух независимых шагов. Дальше каждый шаг можно запускать сам по себе, как в сценарии с подкаткой данных выше.

2.9. Частичный дамп и восстановление

Полный дамп забирает все таблицы целиком. Но иногда нужно лишь их подмножество. Какие‑то таблицы слишком тяжёлые для тестового стенда, какие‑то не нужны конкретной команде, а какие‑то проще вообще не выносить из источника. Для таких случаев у dump и restore есть два списка таблиц, белый и чёрный.

  • --partial-tables-dict-file — белый список, в дамп попадут только перечисленные таблицы,

  • --partial-tables-exclude-dict-file — чёрный список, перечисленные таблицы будут исключены.

Списки задаются в словаре таблиц. Таблицу указывают по имени схемы и таблицы, точно (schema, table) или маской‑регуляркой (schema_mask, table_mask). Маски удобны для партиций и семейств таблиц, когда перечислять каждую вручную не хочется. Формат у обоих списков одинаковый, так что один и тот же файл годится и как белый, и как чёрный, роль задаёт только опция. Если одна и та же таблица указана в белом и чёрном списках, то приоритет у чёрного списка, и таблица исключается.

Вот белый список для нашего примера, сохраним его в файл partial_bank_white_list_tables.py.

{
   "tables": [
       {"schema": "bank", "table": "clients"},
       {"schema": "bank", "table": "accounts"},
       {"schema": "bank", "table": "cards"},
       {"schema": "bank", "table_mask": ".*transactions.*"},
       {"schema": "bank", "table_mask": ".*notifications.*"}
   ]
}

Одна маска .*transactions.* накрывает и родительскую таблицу transactions, и все её партиции, а .*notifications.* забирает notifications, sms_notifications и email_notifications. Всё, что под эти правила не подошло, в дамп не попадёт. Например, таблицы employees в списке нет, и в выгрузку она не уйдёт.

В чёрный список для дампа положим одну таблицу и сохраним его в файл partial_bank_email_notifications_table.py.

{
   "tables": [
       {"schema": "bank", "table": "email_notifications"}
   ]
}

Так из набора, отобранного белым списком, мы дополнительно выкидываем email_notifications.

Команда дампа отличается от полной из раздела 2.6 только этими двумя файлами.

pg_anon dump \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_source \
--prepared-sens-dict-file=bank_sens_dict.py \
--partial-tables-dict-file=partial_bank_white_list_tables.py \
--partial-tables-exclude-dict-file=partial_bank_email_notifications_table.py \
--output-dir=bank_dump_partial

Лог дампа почти дословно повторяется из раздела 2.6, меняется только набор выгружаемых таблиц, поэтому приводить его не будем. В выгрузку вошли ровно отобранные таблицы за вычетом email_notifications: clients, accounts, cards, все партиции transactions, notifications и sms_notifications. Остальные таблицы схемы, например employees, в дамп не пошли.

Кстати, готовый список попавших в дамп таблиц лежит рядом, в файле dumped_tables.py, и сам по себе совпадает с форматом словаря таблиц, поэтому его удобно брать за основу для белых и чёрных списков.

Самое интересное начинается на восстановлении. Здесь работают оба списка, белый и чёрный, и независимо от дампа. В примере ограничимся чёрным, возьмём готовый частичный дамп и при восстановлении дополнительно уберём из него таблицу clients.

Чёрный список для этого сохраним в файл partial_bank_clients_table.py.

{
   "tables": [
       {"schema": "bank", "table": "clients"}
   ]
}

Целевую базу перед восстановлением сбросим начисто:

dropdb -h localhost -p 5432 -U postgres pg_anon_demo_target
createdb -h localhost -p 5432 -U postgres pg_anon_demo_target

Команда восстановления также отличается от полного запуска только передачей файла черного списка.

pg_anon restore \
--db-host=localhost \
--db-port=5432 \
--db-user=postgres \
--db-passfile=pgpass.conf \
--db-name=pg_anon_demo_target \
--input-dir=bank_dump_partial \
--partial-tables-exclude-dict-file=partial_bank_clients_table.py
Сокращённый лог восстановления с чёрным списком:
============> Started pg_anon (v 1.11.0) in mode: restore
Target DB version: 15.18
pg_restore path: /usr/bin/pg_restore
Postgres utils exists checking
-------------> Started restore
PARTIAL RESTORE MODE: CREATE SCHEMA IF NOT EXISTS "anon_funcs"
PARTIAL RESTORE MODE: CREATE SCHEMA IF NOT EXISTS "bank"
PARTIAL RESTORE MODE: CREATE SCHEMA IF NOT EXISTS "public"
PARTIAL RESTORE MODE: CREATE EXTENSION IF NOT EXISTS "pgcrypto" SCHEMA "public" VERSION '1.3'
PARTIAL RESTORE MODE: DO $$
BEGIN    IF NOT EXISTS (SELECT 1 FROM pg_type pt JOIN pg_namespace pn ON pn.oid = pt.typnamespace WHERE pn.nspname = 'bank' AND pt.typname = 'currency')     THEN CREATE TYPE bank.currency AS ENUM ('RUB', 'USD', 'EUR', 'CNY');    END IF;
END;
$$;
…
PARTIAL RESTORE MODE: CREATE DOMAIN bank.account_no AS character varying(20) CHECK (((VALUE)::text ~ '^[0-9]{20}$'::text));
PARTIAL RESTORE MODE: CREATE DOMAIN bank.email AS character varying(254) CHECK (((VALUE)::text ~ '^.+@.+\..+$'::text));
PARTIAL RESTORE MODE: CREATE OR REPLACE FUNCTION bank.fake_full_name() …
PARTIAL RESTORE MODE: CREATE OR REPLACE FUNCTION anon_funcs.partial_email(text) …
…
-------------> Started restore pre-data (pg_restore)
pg_restore: connecting to database for restore
pg_restore: creating TABLE "bank.accounts"
pg_restore: creating TABLE "bank.transactions_2026_01"
…
pg_restore: creating TABLE ATTACH "bank.transactions_2026_01"
…
<------------- Finished restore pre-data (pg_restore)
Skipping restore data of table: "bank"."clients"
>>>>>>>>>>>>>>>>>>>> Started task copy_to_table bank.accounts
>>>>>>>>>>>>>>>>>>>> Started task copy_to_table bank.notifications
…
>>>>>>>>>>>>>>>>>>>> Finished task bank.transactions_default
-------------> Started restore post-data (pg_restore)
pg_restore: skipping item 3376 CONSTRAINT clients clients_pkey
pg_restore: creating CONSTRAINT "bank.accounts accounts_pkey"
pg_restore: creating INDEX "bank.idx_transactions_account"
…
pg_restore: creating INDEX ATTACH "bank.transactions_2026_01_pkey"
…
pg_restore: skipping item 3409 FK CONSTRAINT notifications notifications_client_id_fkey
pg_restore: skipping item 3410 FK CONSTRAINT accounts accounts_client_id_fkey
pg_restore: creating FK CONSTRAINT "bank.transactions transactions_account_id_fkey"
<------------- Finished restore post-data (pg_restore)
SELECT setval(quote_ident('bank') || '.' || quote_ident('accounts_id_seq'), 20000 + 1);
…
-------------> Started analyze
================> Started query analyze "bank"."accounts"
<================ Finished query analyze "bank"."accounts"
…
<------------- Finished analyze
<------------- Finished restore
<============ Finished pg_anon in mode: restore, result_code = done, elapsed: 0.71 sec

В логе сразу видно, чем частичное восстановление отличается от полного. Добавляется пересоздание вспомогательных объектов отдельными DDL‑командами, это схемы, расширения, типы, домены и функции. Такие строки в логе помечены как PARTIAL RESTORE MODE.

Эти объекты не привязаны к конкретной таблице, но без них не создадутся ни сами таблицы, ни функции маскирования. Поэтому при частичном восстановлении pg_anon собирает их сам, а не полагается на pg_restore, который вытягивает только то, что относится к выбранным таблицам.

Дальше идут привычные фазы, только с поправкой на чёрный список. Таблица clients не создаётся, её данные пропускаются, а внешние ключи, которые ссылались на отсутствующие таблицы, снимаются автоматически. Снятый внешний ключ сам не восстановится. Если таблица‑родитель исключена, а ссылающаяся на неё таблица залита, её поле‑ссылка в приёмнике указывает в пустоту — ссылочная целостность нарушена. Для одноразового тестового стенда это обычно не мешает, но состав белого и чёрного списков стоит подбирать с оглядкой на внешние ключи, чтобы висячие ссылки не оказались там, где они важны.

В итоге получаем такую схему восстановленной БД:

В итоге фильтровать можно на обоих концах и независимо друг от друга. Те же два списка, белый и чёрный, доступны и на дампе, и на восстановлении. На дампе они решают, что вообще уходит из источника, а на восстановлении подгоняют конкретный приёмник под нужный набор таблиц уже из готового дампа. Из одного частичного дампа так можно поднимать разные стенды, каждый со своим составом таблиц. Те же списки работают и с раздельными режимами структуры и данных из раздела 2.8, так что сочетаний у всех этих режимов набирается очень много.

2.10. REST API в двух словах

Кроме CLI, у pg_anon есть REST API. Это та же логика, доступная по HTTP, чтобы встраивать маскирование в пайплайны и вызывать её из других сервисов, а не запускать руками.

Перед запуском зададим, куда сервис будет складывать служебные файлы операций. За это отвечает переменная окружения PG_ANON_HOME, по умолчанию это текущий каталог. Для статьи вынесем всё в отдельную папку.

export PG_ANON_HOME=/tmp/pg_anon_demo

Сервер поднимается командой

pg_anon_api

После запуска по адресу http://127.0.0.1:8000/docs есть возможность ознакомиться с документацией API в формате Swagger. При необходимости, можно задать параметры веб‑сервера, такие как: адрес, порт, количество воркеров. Полный список параметров отображается командой

pg_anon_api --help

Важно для эксплуатации. Сервис stateless, внутри он не хранит никакого состояния. На диске остаются только результаты работы, сами дампы, логи операций и копии переданных словарей. Поэтому при запуске в контейнерах не нужно продумывать хранение состояния между перезапусками и репликами. Ещё сервис принимает доступы к боевой БД и сам ходит на вебхуки, поэтому держите его во внутреннем доверенном контуре, а не на публичном адресе: ограничьте сетевой доступ и обращайтесь к нему по защищённому каналу.

Сканирование БД через API

Повторим сканирование из раздела 2.4, но уже через API. База и мета‑словарь те же. Сканирование, дамп и восстановление здесь фоновые, поэтому запрос лишь ставит задачу и сразу отвечает, а результат прилетает на вебхук из webhook_status_url, туда же можно передать заголовки авторизации.

Словарь передаётся не путём к файлу, а прямо текстом в поле meta_dict_contents. Для stateless‑сервиса это логично — доступа к вашей файловой системе у него нет.

С этим связаны два момента безопасности по обоим концам запроса. Тело запроса целиком содержит секреты: и доступы к БД, и токены в заголовках вебхука. Не храните такой запрос в открытом виде, подставляйте пароль и токены на лету из переменной окружения или менеджера секретов и следите, чтобы они не оседали в логах вызывающей стороны. В примере ниже user_password — тот же тестовый postgres от нашего стенда, что и в 2.2; в реальном запросе на его месте ваш секрет. А на другом конце, в ответ на сканирование, на webhook_status_url прилетают собранные словари — по сути карта чувствительных полей, то есть где именно в базе лежат ПДн. Поэтому приёмником вебхука тоже должен быть доверенный, контролируемый вами сервис, а не первый попавшийся публичный адрес.

Тело запроса положим в файл scan_request.json:

{
"operation_id": "scan-0001",
"db_connection_params": {  "host": "localhost",  "port": 5432,  "db_name": "pg_anon_demo_source",  "user_login": "postgres",  "user_password": "postgres"
},
"webhook_status_url": "<url к сервису, который будет читать статусы операций>",
"webhook_extra_headers": {  "Authorization": "Api-Key mySuperSecretKey",  "Accept": "application/json",  "X-MyService-Token": "my-service-token"
},
"webhook_verify_ssl": true,
"type": "full",
"meta_dict_contents": [ {   "name": "мета-словарь из статьи",   "content": "{\n    \"field\": {\n        \"constants\": [\n            \"full_name\", \"holder_name\", \"counterparty_name\",\n            \"pan\", \"account_no\", \"passport\", \"birth_date\",\n            \"inn\", \"message_text\",\n        ],\n        \"rules\": [\n            \".*phone.*\",\n            \".*email.*\"\n        ],\n    },\n    \"sens_pg_types\": [\"text\"],\n    \"data_sql_condition\": [\n        {\"schema\": \"bank\", \"table_mask\": \"^transactions\", \"sql_condition\": \"WHERE channel = 'swift'\"},\n    ],\n    \"data_func\": {\n        \"text\": [\n            {\n                \"scan_func\": \"bank.is_valid_inn\",\n                \"anon_func\": \"anon_funcs.random_in(ARRAY['Спасибо за помощь, вопрос решён.', 'Как изменить лимит по карте в приложении?', 'Подскажите график работы отделения в выходные.', 'Хочу уточнить статус моей заявки.'])\",\n            },\n        ],\n    },\n    \"data_const\": {\n        \"partial_constants\": [\"паспорт\", \"снилс\", \"водительск\", \"Код подтверждения\"],\n    },\n    \"data_regex\": {\n        \"rules\": [\n            r\"\"\"[A-Za-z0-9._-]+@[A-Za-z0-9-]+\\.[A-Za-z]{2,}\"\"\",\n            r\"\"\"\\d{4}\\s\\d{4}\\s\\d{4}\\s\\d{4}\"\"\",\n        ],\n    },\n    \"funcs\": {\n        \"bank.phone\": \"anon_funcs.random_phone('+79')\",\n        \"bank.email\": 'anon_funcs.partial_email(\"%s\")',\n        \"bank.pan\": '''anon_funcs.partial(\"%s\", 6, '000000', 4)''',\n        \"bank.account_no\": 'bank.mask_account_no(\"%s\")',\n        \"bank.passport\": '''CASE WHEN \"%s\" IS NULL THEN NULL ELSE bank.fake_passport() END''',\n        \"bank.inn\": '''CASE WHEN \"%s\" IS NULL THEN NULL ELSE bank.fake_inn() END''',\n        \"date\": '''anon_funcs.dnoise(\"%s\"::timestamp, interval '1 month')''',\n        \"character varying(120)\": \"bank.fake_full_name()\",\n        \"character varying(50)\": \"bank.fake_holder_name()\",\n        \"character varying(255)\": \"anon_funcs.random_in(ARRAY['Операция выполнена. Баланс обновлён.', 'Перевод зачислён.', 'Платёж проведён успешно.'])\",\n        \"text\": \"anon_funcs.random_in(ARRAY['Спасибо за помощь, вопрос решён.', 'Как изменить лимит по карте в приложении?', 'Подскажите график работы отделения в выходные.', 'Хочу уточнить статус моей заявки.'])\",\n        \"default\": \"anon_funcs.digest(\\\"%s\\\", '<CHANGE_ME_SECRET_SALT>', 'sha256')\",\n    },\n}" }
],
"need_no_sens_dict": true,
"save_dicts": true
}

Мета‑словарь здесь тот же, что в разделе 2.4, только уложенный в строку content и без комментариев. Далее отправим запрос:

curl -X POST http://127.0.0.1:8000/api/stateless/scan \
-H "Content-Type: application/json" \
-d @scan_request.json

В ответ приходит 201, задача принята. На webhook_status_url прилетит статус операции «in_progress» вместе с идентификатором операции internal_operation_id.

Когда сканирование закончится, на вебхук прилетит статус «success», вместе с получившимися словарями чувствительных и нечувствительных данных. Если в процессе сканирования что‑то пойдёт не так, то придёт статус «error».

Этот скрин наглядно показывает оба риска, о которых мы говорили до запроса, — здесь они видны разом. Вверху — те самые кастомные заголовки, что мы отправили в webhook_extra_headers, вместе с токеном в Authorization. Сквозная передача заголовков нужна по делу: достучаться до внутреннего сервиса с авторизацией или протянуть трассировочный идентификатор. Но ниже, в теле того же вызова, лежит результат сканирования — собранные словари, и словарь чувствительных данных это карта того, где в базе лежат ПДн. И токен, и эта карта ушли на публичный сервис. В демо так можно лишь потому, что и ключ, и база заведомо тестовые; в реальной работе шлите вебхук только на доверенный, контролируемый вами приёмник и предпочитайте недолговечные токены.

Теперь посмотрим, что осталось на диске. Каждая операция, и сканирование, и дамп, и восстановление, оставляет след в каталоге pg_anon_runs внутри PG_ANON_HOME, разложенный по дате и идентификатору запуска. В каталоге запуска лежат:

  • run_options.json со всеми параметрами запуска, но при этом без пароля от БД;

  • run_status.json со статусом операции и её длительностью;

  • logs/logs.log с полным логом;

  • Так как мы указали save_dicts, все входные словари были сохранены в папке input, а выходные словари в папке output. Также всё содержимое словарей продублировано в файле saved_dicts_info.json.

Ровно для этого и нужен параметр save_dicts. По каталогу запуска видно, с чем операция стартовала. Из saved_dicts_info.json достаются сами словари, из run_options.json параметры запуска, рядом лежат логи. Этого хватает, чтобы собрать команду заново и повторить запуск один в один. На отладке и при разборе инцидентов это сильно выручает, видно, какими словарями и с какими параметрами реально обрабатывали данные.

Практика

Демо‑БД уже поднята, а словари собраны, так что лучший способ закрепить материал — поменять правила под себя и прогнать тот же цикл заново. Ничего нового ставить не нужно, всё делается на той же базе командами из разделов 2.4–2.7.

Задание: измените маскирование и убедитесь, что результат поменялся.

Откройте мета‑словарь bank_meta_dict.py и измените две вещи:

  1. Сузьте поиск. Оставьте в field.constants только часть полей (например, full_name и passport) и уберите одно‑два правила из data_regex или data_const. После пересканирования в словарь чувствительных данных попадёт меньше полей;

  2. Смените функции маскирования. В блоке funcs поменяйте маску для какого‑нибудь типа. Например, телефон (bank.phone) вместо random_phone('+79') замаскируйте частично — anon_funcs.partial(“%s”, 2, '****', 2), а ФИО (character varying(120)) — стабильным хешем anon_funcs.digest(“%s”, '', 'sha256') вместо bank.fake_full_name(). После пересканирования и предпросмотра увидите, что вывод изменился.

После изменения мета‑словаря прогоните сканирование в режиме create-dict, как в разделе 2.4. Затем, с помощью полученного словаря чувствительных данных проверьте через view-fields, какие поля будут маскироваться, а через view-data как изменятся данные, как в разделе 2.5. И как только убедитесь, что всё в порядке, прогоните полный цикл dump и restore (разделы 2.6, 2.7) и убедитесь, что в приёмнике данные замаскированы уже по вашим правилам.

Если захотите задать функцию точечно на конкретное поле, а не на весь тип, правьте уже собранный словарь чувствительных данных bank_sens_dict.py напрямую.

Таким образом, получится своя конфигурация маскирования, собранная и проверенная от начала до конца. Дальше на этой же базе легко пробовать частичные и раздельные режимы из разделов 2.8, 2.9.

Дополнительное задание. В предпросмотре было видно, что у юрлиц наименование организации превращается в ФИО. На поле full_name стоит единая маска fake_full_name. Сделайте так, чтобы физлицам подставлялось ФИО, а юрлицам — название организации. Это условие по соседней колонке client_type, поэтому правило пишется прямо в словаре чувствительных данных bank_sens_dict.py тем же приёмом CASE, что и для транзакций.

Заключение

Мы разобрали все ключевые нововведения, прошли весь цикл работы с pg_anon на реалистичной банковской базе: от сканирования и сборки словаря до полного дампа с восстановлением, раздельных и частичных режимов и REST API. Сложные конструкции из реальных схем, партиции с внешними ключами, identity, составные типы и спецсимволы в именах, прошли весь цикл штатно, без ручной подготовки приёмника и без правки дампа. На выходе — работающая копия прода: та же структура, а на месте реальных ПДн — синтетика. Прямые идентификаторы убраны, но связи между записями сохранены — для разработки и тестов это то, что нужно, а для выноса за контур стоит оценить, не выдадут ли человека сами эти связи.

Что нужно, чтобы применить это у себя

Настройка маскирования — работа для инженера, который знает вашу схему: он составляет и проверяет словарь, а для этого ему нужен доступ к боевой базе (на чтение — маскирование идёт при чтении, саму базу pg_anon не меняет). Трудозатраты зависят от схемы: на простой базе словарь собирается быстро, на сложной — с нестандартными типами и партициями — дольше, а если нужно не просто маскирование, а полное обезличивание, то это отдельная работа. Основная часть разовая; дальше остаётся сопровождение — словарь обновляют, когда меняется схема или правила.

Полезные ссылки:

pg_anon развивается абсолютно открыто. Будем рады обратной связи, баг‑репорты особенно ценны на нетривиальных схемах, а pull request'ы всегда можно прислать на GitHub.