Назад к блогу

Роль-уровневые ограничения в Postgres не защищают: сессия снимает их одним запросом

Роль-уровневые ограничения в Postgres не защищают: сессия снимает их одним запросом

Настройки, назначаемые роли через `ALTER ROLE ... SET`, часто воспринимают как жёсткий барьер — скажем, режим «только чтение» или ограничение на длительность запросов. Однако на деле это лишь стартовые параметры сессии, которые сама сессия способна переопределить, в том числе одним запросом. В статье разбирается механика этого поведения и объясняется, какие механизмы в действительно надёжно удерживают сессию в заданных рамках.

ALTER ROLE ... SET выглядит как способ выдать роли «урезанную» конфигурацию: только чтение, короткий таймаут, запрет долгих блокировок. На практике всё это — стартовые значения, которые сессия меняет сама. Разберём, почему так устроено, как именно снимается ограничение и что действительно ограничивает сессию.

Что обещает защита на уровне строк

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

Выражение политики обязано давать логический результат и вычисляется для каждой строки до условий и функций пользовательского запроса; строки, для которых выражение не даёт истину, не обрабатываются. Для вставки и обновления используется check_expression. Если для любой вставляемой или обновляемой записи оно даёт ложь или неопределённость, выбрасывается ошибка. Если у политики задан только USING, она неявно получает WITH CHECK, идентичный USING, поэтому ограничение действует и на выбираемые, и на изменяемые строки.

Комбинирование политик подчиняется жёстким правилам. Все PERMISSIVE-политики объединяются через OR, все RESTRICTIVE-политики — через AND, а результаты — тоже через AND. При отсутствии PERMISSIVE-политик доступ запрещается. Политики разных типов команд, применимые к одной команде, объединяются через AND, поскольку пользователь должен обладать обоими видами прав.

Обход защиты встроен в саму модель. Суперпользователи и роли с атрибутом BYPASSRLS всегда обходят систему защиты строк. Владелец таблицы обычно тоже обходит её, но может подчиниться через ALTER TABLE ... FORCE ROW LEVEL SECURITY. Проверки ссылочной целостности — уникальные и первичные ключи, ссылки по внешним ключам — всегда обходят защиту строк, чтобы сохранить целостность данных; политики не применяются при внутренних проверках ссылочной целостности и валидации ограничений.

Как политики превращаются в условия плана

Политики не применяются к строкам напрямую — они становятся частью запроса на этапе перезаписи. Для каждого RTE вызывается get_row_security_policies(). Она возвращает выражения политик и добавляет их в securityQuals, а для команд изменения — в WithCheckOptions.

Сбор политик для отношения идёт по трём ключам: идентификатору отношения, типу команды и роли. Каждая запись pg_policy проверяется на соответствие команде и роли; подходящая политика попадает в список permissive или restrictive в зависимости от своего флага. Соответствие роли определяется так: если первая роль в массиве ролей политики — ACL_ID_PUBLIC, политика применяется ко всем; иначе перебираются все роли и для каждой проверяется has_privs_of_role(user_id, roles[i]), то есть учитывается не только прямое совпадение, но и членство в ролях.

Сортировка применяется только к restrictive-политикам: они сортируются по имени, чтобы проверки над новой версией строки выполнялись в определённом порядке. Permissive-политики не сортируются, так как объединяются через OR. Restrictive-политики от хуков также сортируются по имени и добавляются после встроенных restrictive; permissive-хуки просто добавляются в конец списка, если проходят проверку роли.

Дальше USING-выражения permissive-политик собираются в список и объединяются в одно выражение: если политика одна, берётся она сама, иначе строится OR-выражение. Это выражение добавляется в список условий видимости строк, а restrictive-политики добавляются по отдельности тем же способом. Если permissive-политик не найдено, добавляется всегда-ложная константа, чтобы ни одна строка не была видна.

Для команд изменения логика аналогична, но с важным отличием: permissive-условия объединяются в одну проверку без имени политики, а для каждой restrictive-политики создаётся отдельная проверка с её именем, чтобы ошибка указывала конкретную политику. Если permissive-условий нет, добавляется одна всегда-ложная проверка, запрещающая вставку новых строк.

Итоговое выражение выглядит так:

expression from RESTRICTIVE policy 1 AND ... AND (expression from PERMISSIVE policy 1 OR ...)

Особые случаи: конфликты, RETURNING, блокировки

Для INSERT ... ON CONFLICT DO SELECT/UPDATE добавляются дополнительные проверки политик для того же RTE. Если требуются права ACL_UPDATE, политики UPDATE добавляются как проверка конфликтующей строки: она срабатывает при попытке обновить или заблокировать строку, к которой привёл конфликт, и вместо молчаливого отбрасывания изменения возникает ошибка. Если требуются права ACL_SELECT, политики SELECT/ALL добавляются как такая же проверка конфликтующей строки, чтобы данные не отбрасывались молча при выборе пути SELECT/UPDATE. Для ONCONFLICT_UPDATE дополнительно WITH CHECK политики UPDATE добавляются как проверка обновляемой строки, а при ACL_SELECT — SELECT/ALL политики как такая же проверка обновляемой строки.

Для MERGE политики UPDATE и DELETE добавляются как проверки USING на существующей строке — отдельно для обновления и для удаления, WITH CHECK UPDATE — как проверка обновляемой строки, INSERT — как проверка вставляемой строки. Политики SELECT/ALL для INSERT добавляются как проверка вставляемой строки только при наличии RETURNING и ACL_SELECT.

Почему сессия снимает ограничения: контекст GUC

Ключ к пониманию — контекст GUC. statement_timeout, lock_timeout, default_transaction_read_only, application_name, transaction_timeout, idle_in_transaction_session_timeout и idle_session_timeout — все имеют контекст user. Контекст user определён как право любой сессии менять своё локальное значение.

ALTER ROLE ... SET записывает лишь значение, с которым сессия начинается. Остаётся ли она на нём — целиком дело сессии.

Приоритет источников значений GUC

Значение, установленное через ALTER ROLE ... SET, записывается с источником user. Когда клиент передаёт параметры через options в стартовом пакете libpq, они применяются уже после загрузки настроек роли. Сессия стартует с source = client, который стоит выше роли user, и предел снимается ещё до выполнения первого запроса.

Источники значений GUC упорядочены по приоритету: client перекрывает user, а session — результат обычного SET — перекрывает оба. То же самое происходит при обычном SET: до него pg_settings.source показывает user, после — session. Это единственное место, где изменение видно.

Отозвать право на изменение параметра контекста user нельзя. GRANT SET ON PARAMETER существует, чтобы выдать не-суперпользователю параметр, который он иначе не мог бы трогать, — значит, имеет смысл только для параметров в контекстах superuser и superuser-backend. Для параметра с контекстом user записывать нечего: отзываемая способность никогда не выдавалась, она уже есть у каждой роли. Команда REVOKE SET ON PARAMETER statement_timeout FROM agent принимается, и это всё, что она делает; в pg_parameter_acl не появляется строк — отсутствие строки означает, что никакого ограничения не зафиксировано.

Векторы обхода: SET, PGOPTIONS, DSN options

Клиент задаёт значение с источником client двумя способами. Первый — строка подключения с options=-c statement_timeout=0: libpq передаёт параметр подключения options как командные переключатели в стартовом пакете, и они применяются после загрузки настроек роли. Второй — переменная окружения PGOPTIONS="-c statement_timeout=0" при запуске psql. В обоих случаях никакой отдельной команды SET не выполняется, поэтому в логах нечего искать. Грамотно написанные клиентские библиотеки делают это по умолчанию, если таймаут указан в конфигурационном файле.

Что видит клиент и как это выглядит снаружи

При активных ограничениях SELECT pg_sleep(4) отменяется на двух секундах, а CREATE TABLE отклоняется. После снятия ограничений сессия выполняет такие операции без отказа. В pg_settings меняется только столбец source: до SET там было user — конфигурация самой роли, после — session. Этот столбец — единственное место, где изменение видно, и никто за ним не следит.

Администратор может найти подходящие сессии через pg_stat_activity, отфильтровав по имени роли, состоянию active и длительности выполнения запроса, а затем прервать их. pg_cancel_backend отменяет только текущий оператор и оставляет соединение открытым, после чего клиент просто запускает что-то другое. Одноаргументная форма pg_terminate_backend возвращается сразу после доставки SIGTERM и ничего не сообщает о том, ушёл ли backend; двухаргументная форма pg_terminate_backend(pid, 5000) ждёт завершения. Чтобы драйвер не переподключился, пока администратор ещё читает строку лога, следует сначала отобрать логин через ALTER ROLE agent NOLOGIN.

Проверка реальных возможностей роли

Проверять возможности роли нужно запросом, который для каждой роли, способной логиниться, вызывает pg_has_role. Ей передают три аргумента: OID проверяемой роли, имя роли, права которой проверяются, и режим 'USAGE' — этот режим означает проверку именно использования прав другой роли, а не владения ею или права передавать их дальше, поэтому он и показывает реальные возможности. В примере это делается для 'pg_execute_server_program' (может ли роль запускать программы) и 'pg_read_server_files' (может ли читать файлы), а фильтр r.rolcanlogin оставляет только роли, которые могут входить в систему. Такой запрос следует выполнять для каждой login-роли в кластере, прежде чем доверять любой полученной «инвентаризации» прав.

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

Что из этого следует на практике

Роль-уровневые настройки вроде default_transaction_read_only = on не являются контролем безопасности: это значение по умолчанию в контексте user, и сессия снимает его одним оператором или вообще не принимает. Такие настройки относятся к значениям по умолчанию, предотвращающим случайности, а не к средствам остановки атакующего. statement_timeout, останавливающий аналитический запрос через две секунды, полезен, но это эргономика, а не безопасность.

Надёжно ограничивает сессию направление агента на реплику: на hot standby режим только чтения обеспечивается механизмом recovery, ниже уровня любых параметров, и его не снимают ни SET default_transaction_read_only = off, ни строка подключения с options, причём ограничение доходит до временных таблиц.

Принудительный таймаут на соединениях через пулинг или другое middleware опасен: такой слой может плохо реагировать на неожиданное закрытие соединения. Его можно включать только для интерактивных сессий, возможно, только для отдельных пользователей.

Безопасность обеспечивают pg_hba.conf — самая ранняя граница, решающая, какая роль к какой базе откуда может подключиться, до разбора SQL, — список грантов, проверяемых сервером на каждом операторе, атрибуты роли, watchdog и реплика. Для аудита нужно логировать фактическое значение из pg_settings, а не из rolconfig.

Где смотреть в коде

Источники

Похожее