transp_anon – динамическое маскирование через Access Methods в PostgreSQL

от автора

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