Меня зовут Илья Рожнёв, я разработчик СУБД в ”Тантор Лабс”. Мы могли с вами видеться на московском 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 bool
transp_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 (список пополняется):





















