Меня зовут Илья Рожнёв, я разработчик СУБД в ”Тантор Лабс”. Мы могли с вами видеться на московском PG BootCamp Russia 2026 где я выступал с докладом про OLAP-нагрузки на реплике. Это моя первая статья на Хабре, поэтому буду рад вашему фидбеку.
Вступление
Enterprise-разработка рано или поздно сталкивается с классической задачей: нужно выдать доступ к базе данных аналитикам, тестировщикам или саппорту в проде, но при этом необходимо скрыть персональные данные или коммерческую тайну. В прошлой статье, Александр Дубов рассказывал о pg_anon – инструменте статического маскирования данных, которое отлично подходит для случаев, когда таблица копируется полностью. Но что если “замаскировать” нужно просто результат какого-либо запроса? Здесь пригодится маскирование динамическое, и сегодня я расскажу об инструменте transp_anon, который входит в новый релиз СУБД Tantor Postgres 18.
Вообще, поскольку в ванильном PostgreSQL «из коробки» полноценного динамического маскирования на уровне ядра нет, задачу приходится решать с помощью расширений или сторонних инструментов, таких как:
-
другие инструменты маскирования, например, через прокси.
Нам понравилось решение pg_anonymize. Мы активно использовали и развивали этот инструмент, сделав свой форк transp_anon, но в процессе эксплуатации мы столкнулись с архитектурными ограничениями оригинального расширения, которое приводило к утечкам данных. Чтобы понять, из-за чего маскируемые данные могли попасть “не в те руки”, пришлось разобраться в том, как работала старая архитектура.
Как работала legacy-архитектура transp_anon
Разработчик схемы базы задает для колонок таблицы правила маскировки. Для обычных пользователей данные не меняются, но если к базе подключился пользователь с меткой MASKED, расширение включается в работу.
-- создадим маскируемую рольCREATE ROLE skynet LOGIN;GRANT SELECT ON TABLE people TO skynet;SECURITY LABEL FOR transp_anon ON ROLE skynet IS 'MASKED';-- добавим правила маскировки для колонокSECURITY LABEL FOR transp_anon ON COLUMN people.lastname IS 'MASKED WITH FUNCTION transp_anon.fake_last_name()';SECURITY LABEL FOR transp_anon ON COLUMN people.phone IS 'MASKED WITH FUNCTION transp_anon.partial(phone,2,$$******$$,2)';--меняем роль на маскируемуюALTER ROLE skynet;select * from people;id | firstname | lastname | phone----+-----------+-----------+------------T1 | Sarah | Stranahan | 06******11
Здесь магия маскировки работает через подход, называемый Query Rewriting. На этапе post_parse_analyze расширение “смотрело” на сам запрос и модифицировало его, подменяя прямой доступ к колонкам, на вызов функций. В псевдокоде это выглядит так:
-- оригинальный запрос, правила маскировки мы создали вышеSELECT * FROM people;-- после того как transp_anon обработал запросSELECT id, firstname, transp_anon.fake_last_name() AS lastname, transp_anon.partial(phone, 2, '******', 2) AS phone FROM people;
Для простых кейсов, как SELECT выше, все работает идеально. Executor и Planner сами решают, как максимально быстро выполнить запрос оптимальным способом. Но реальные запросы далеко не таковы…
Почему не подходил Query Rewriting?

Как только начали тестировать реальные запросы в реальных сценариях использования, то сразу стали получать критические проблемы с утечкой данных. Что, например, произойдет, если пользователь выполнит один из следующих сценариев?
-
Вызов процедур и функций (и, не дай Бог, функции написаны на C…)
-
Использование prepared statements
-
Запросы к VIEW
-
Наследуемые таблицы
Если со временем мы и научились перехватывать сценарии выше, то на каких-нибудь более комплексных сценариях все ломалось, например:
CREATE VIEW complex_people_view AS SELECT concat(id::text, 2) AS mixed_id, data || 'some_postfix' AS masked_data FROM people;
Чтобы понять, что внутри строковых конкатенаций спрятана маскируемая колонка, нужен полноценный парсер, способный распутать граф зависимостей внутри СУБД. И чем больше кейсов мы проверяли, тем больше понимали, что подход с подменой запроса – это тупик, который тяжело поддерживать без риска утечки данных.
Наше решение: маскирование на этапе выдачи кортежа.
Мы задумались о том, как же все-таки прогарантировать 100% защиту от утечки данных. Как не выдать конфиденциальные данные, несмотря на сложность SQL-запроса? Что если у пользователя расширение работает напрямую с Postgres API?
Решение было на поверхности: зачем маскировать сам запрос, если можно маскировать сами данные! Когда Executor обращается к данным, он делает это через AM (Access Methods), например как slot_getnext, и мы перехватываем этот процесс. Мы получаем кортеж, смотрим в системный каталог(pg_seclabels), проверяем, есть ли для данного Relation правила маскировки, и модифицируем значение в кортеже. Выглядит это примерно так:
typedef struct MaskingAmWrapper{ TableAmRoutine base; /* copy of the original AM */ const TableAmRoutine *orig; /* pointer to the original AM */ ParsedSeclabels* rules; /* cached masking rules */} MaskingAmWrapper;static booltransp_anon_generic_getnextslot(TableScanDesc scan, ScanDirection direction, TupleTableSlot *slot){ MaskingAmWrapper *wrapper = (MaskingAmWrapper *) scan->rs_rd->rd_tableam; bool ok = wrapper->orig->scan_getnextslot(scan, direction, slot); if (!ok) return false; if (transp_anon_mask_slot(scan->rs_rd, slot, wrapper->rules)) { ExecClearTuple(slot); ExecStoreVirtualTuple(slot); } return true;}
Для Postgres этот процесс становится абсолютно прозрачным. Планировщик и исполнитель думают, что работают с самыми обычными данными из таблицы. Нам больше не надо парсить сложные запросы, раскручивать вложенные VIEW. Любой доступ к таблице, как бы он глубоко ни был спрятан, всё равно будет проходить через наши обертки.
Однако проблемы выплыли в другом месте.
Производительность
С новым подходом всё было здорово, но поскольку маскировка стала работать в Postgres куда глубже, мы теряем контекст SQL-запроса. Мы попросту больше не знаем, какие именно колонки запросил пользователь и какие строки должны быть отфильтрованы.
Проблема 1:
сделаем таблицу:
CREATE TABLE test_table (col1 text, col2 text, col3 text, col4 text);-- на все колонки создадим правила маскировки
и выполним запрос:
SELECT col1 FROM test_table;
transp_anon 1.0 видел, что запрашивается только колонка col1, и подменял только ее, другие маскировки в запросе не участвовали.
transp_anon 2.0 не знал, что нужно пользователю, – он видел только данные и какие маскировки применять.
Если в качестве маски используются легковесные константы (например MASKED WITH VALUE ‘hidden’), то разница незаметна. Но если использовать тяжелые функции псевдо-анонимизации, внутри которых зашита логика хэширования, обращения к словарям, то разница улетала в космос.
Проблема 2:
Что если запрос будет выглядеть так:
SELECT FROM test_table WHERE masked_col3 = 'И*Н' AND masked_col2 = 'И***ИЧ';
Когда кортеж попадает в slot с диска, он уходит на фильтрацию. В псевдокоде это выглядит так:
if (transp_anon.mask_func(tuple.col3) != "И**Н") { return false; } if (transp_anon.mask_func(tuple.col2) != "И***ИЧ") { return false; } return true;
Очевидно, если не прошло первое условие, то не нужно проверять второе, да и вообще, не нужно маскировать данные. Но в нашем случае маскировка прошла еще до этапа фильтрации, и это тоже негативно влияло на производительность.
Красивое решение производительности: Custom Scan
Для решения проблемы мы используем Custom Scan. Этот механизм позволяет вмешаться в процесс построения плана запроса и заменить стандартные методы сканирования на кастомные. Благодаря внедрению Custom Scan в transp_anon мы смогли вернуть контекст маскирования, сохранив при этом надежность маскирования на уровне кортежей.
-
Проекция колонок: Теперь на этапе планирования наш
Custom Scanузел точно знает, какие именно колонки реально затребованы вSELECT. Мы динамически отключаем вызовы маскирующих функций для тех колонок, которые физически не участвуют в формировании ответа. -
Ленивое маскирование и Pushdown-фильтры: Мы научили transp_anon координировать маскирование с фильтрами. Если строка не проходит условия выборки, тяжелые псевдо-функции анонимизации для неё просто не вызываются.
В итоге это позволило совместить в transp_anon безопасность и скорость работы!
Заключение
Перенос логики маскирования с уровня синтаксической перезаписи SQL-запросов на уровень физических кортежей с использованием Custom Scan позволил нам закрыть все критические бреши в безопасности transp_anon. Теперь ни сложные View, ни prepared statements, ни вложенные вызовы функций не приведут к случайной утечке защищаемых данных.
А как вы решаете задачу маскирования данных в своих проектах? Сталкивались ли с утечками при использовании стандартных View? Поделитесь опытом в комментариях!
Другие статьи о нововведениях релиза СУБД Tantor Postgres 18 (список пополняется):
ссылка на оригинал статьи https://habr.com/ru/articles/1046107/