Назад к блогу

Бесконечная рекурсия в политиках RLS и цена исправлений

Бесконечная рекурсия в политиках RLS и цена исправлений

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

Политики RLS подключаются к запросу не при разборе, а позже — на шаге rewrite. Именно из-за этого момента подключения возникает риск бесконечной рекурсии: условия, добавленные в дерево запроса после парсинга, сами могут содержать подзапросы, которые снова проходят через rewrite и снова получают политики. Разберём, как устроен этот конвейер, где именно замыкается рекурсия и какие проверки её останавливают.

Где в конвейере подключаются политики

Обработка запроса в PostgreSQL идёт по шагам parse → rewrite → plan → execute. Политики RLS подключаются на шаге rewrite: для обычных запросов это делается вызовом get_row_security_policies() во время rewrite, для каждого RTE. Функция возвращает два списка: securityQuals — выражения USING из политик, фильтрующие видимые строки, и withCheckOptions — узлы WithCheckOption, проверяющие добавляемые строки. Дополнительно она выставляет два флага: hasRowSecurity и hasSubLinks.

В rewriteHandler.c после вызова эти списки встраиваются в дерево запроса: securityQuals помещаются в начало rte->securityQuals, а withCheckOptions — в начало parsetree->withCheckOptions. Добавление именно в начало означает, что RLS-условия применяются раньше уже существующих barrier quals.

rte->securityQuals = list_concat(securityQuals,
								 rte->securityQuals);

parsetree->withCheckOptions = list_concat(withCheckOptions,
										  parsetree->withCheckOptions);

Поскольку условия добавляются уже после парсинга, для подзапросов в них приходится отдельно брать блокировки через acquireLocksOnSubLinks и прогонять RIR-правила. Если RLS на таблице нет (RLS_NONE — политики отсутствуют полностью), функция сразу возвращает управление. Если RLS_NONE_ENV — политик тоже нет, но решение зависит от окружения (например, от текущей роли), поэтому ставится только hasRowSecurity=true без добавления условий — чтобы запрос перепланировался при смене окружения.

Как собираются политики

Функция get_policies_for_relation получает список политик из relcache через RelationGetRowSecurityDesc и обходит его в порядке хранения в списке policies. Для каждой политики проверяются два условия: совпадает ли её команда с запрошенной и входят ли роли политики в число ролей пользователя.

Роль пользователя user_id вычисляется как perminfo->checkAsUser, если он задан, иначе как GetUserId(). Проверка роли check_role_for_policy сначала смотрит, равна ли первая роль политики ACL_ID_PUBLIC — тогда политика применяется ко всем ролям сразу:

if (roles[0] == ACL_ID_PUBLIC)
	return true;

Иначе перебираются роли политики и проверяется has_privs_of_role(user_id, roles[i]) — обладает ли пользователь привилегиями указанной роли.

Подходящие политики раскладываются в два списка: permissive-политики и restrictive-политики. После обхода restrictive-политики сортируются по имени через sort_policies_by_name, чтобы их WithCheckOptions проверялись в определённом порядке. Permissive-политики не сортируются, так как все они объединяются через OR в одну проверку. Затем к спискам добавляются политики от расширений: сначала restrictive-хуки (тоже сортируются по имени и добавляются после встроенных), затем permissive-хуки.

Разделение ALL/SELECT/INSERT/UPDATE/DELETE в этой функции не выполняется — оно уже учтено в признаке совпадения команды политики с типом выполняемой команды (или её применимости ко всем командам), а код лишь фильтрует по нему и по роли.

Как формируется итоговое условие

В add_with_check_options сначала собираются квалификации только permissive-политик в список permissive_quals. Для каждой политики берётся её WITH CHECK-выражение, а если его нет — USING-выражение. Если permissive_quals не пуст, все permissive-квалификации объединяются одним WithCheckOption через OR (при одной квалификации она берётся как есть), причём имя политики не задаётся (polname=NULL), так как провал означает, что ни одна политика не разрешила операцию.

Затем для каждой restrictive-политики создаётся отдельный WithCheckOption с её собственным именем, и эти проверки соединяются через AND — так в ошибке можно указать конкретную нарушенную политику.

Если же permissive_quals пуст, добавляется один всегда-ложный WCO (default-deny):

wco->qual = (Node *) makeConst(BOOLOID, -1, InvalidOid, sizeof(bool), BoolGetDatum(false), false, true);

Поэтому новые строки не проходят ни при каких условиях. Общее правило: все permissive-выражения объединяются через OR, все restrictive — через AND, результаты — через AND; при отсутствии permissive-политик доступ запрещается.

WITH CHECK и запись: как SELECT-права влияют на модификацию

При UPDATE, DELETE или MERGE, если требуются также права SELECT, сначала собираются политики CMD_SELECT и добавляются как security quals через add_security_quals — чтобы отфильтровать записи, невидимые через ALL или SELECT USING-политику. Для INSERT и UPDATE при наличии ACL_SELECT те же SELECT-политики добавляются как withCheckOptions с force_using=true, чтобы при нарушении политики возникала ошибка, а не молчаливое отбрасывание строк.

WithCheckOption (WCO) — это проверка, которой должна удовлетворять строка при записи; её вид задаёт, к какому действию относится проверка. Для формы INSERT ... ON CONFLICT DO SELECT/UPDATE добавляются дополнительные проверки, потому что к тому же RTE могут применяться политики SELECT/UPDATE. Если требуются права ACL_UPDATE, берутся политики CMD_UPDATE и их USING-условия проверяются как WCO_RLS_CONFLICT_CHECK — так при невозможности обновить или заблокировать конфликтующую строку из-за RLS возникает ошибка, а не тихое отбрасывание изменения. Если требуются права ACL_SELECT, берутся политики CMD_SELECT и также добавляются как WCO_RLS_CONFLICT_CHECK. Для формы ON CONFLICT DO UPDATE дополнительно добавляются WCO_RLS_UPDATE_CHECK.

Для MERGE порядок навешивания на withCheckOptions такой: WCO_RLS_MERGE_UPDATE_CHECK, затем WCO_RLS_UPDATE_CHECK от UPDATE-политик, затем WCO_RLS_UPDATE_CHECK от SELECT-политик, затем WCO_RLS_MERGE_DELETE_CHECK, затем WCO_RLS_INSERT_CHECK, и последними — WCO_RLS_INSERT_CHECK от SELECT-политик (только при наличии RETURNING и ACL_SELECT).

Если пользователь имеет UPDATE, но не имеет SELECT, ветка с ACL_SELECT не выполняется, и SELECT-политики не добавляются; при этом для INSERT/UPDATE всё равно добавляются withCheckOptions на основе политик UPDATE/INSERT. Согласно документации, если у модифицирующего запроса есть RETURNING, требуются права SELECT, и новые строки должны удовлетворять SELECT-политикам, иначе выбрасывается ошибка.

Где именно замыкается рекурсия

В fireRIRrules после вызова get_row_security_policies полученные списки присоединяются к RTE и к запросу. Если установлен hasSubLinks, перед добавлением выполняется acquireLocksOnSubLinks для обоих списков — чтобы захватить блокировки на отношения, на которые ссылаются подзапросы, поскольку условия добавляются после парсинга. Затем по обоим спискам проходит expression_tree_walker с fireRIRonSubLink, который для каждого узла SubLink заменяет подзапрос результатом fireRIRrules. Именно здесь и происходит повторный вход в rewrite: fireRIRrules внутри себя снова обрабатывает RTE, снова может вызвать get_row_security_policies и снова fireRIRonSubLink для вложенных подзапросов.

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

if (list_member_oid(activeRIRs, RelationGetRelid(rel)))
	ereport(ERROR,
			(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
			 errmsg("infinite recursion detected in policy for relation \"%s\"",
					RelationGetRelationName(rel))));

Обход rtable в fireRIRrules выполняется циклом while с индексом, а не foreach, потому что список rtable может меняться на каждой итерации. Подлинки обрабатываются отдельно через query_tree_walker с QTW_IGNORE_RC_SUBQUERIES, чтобы не обходить уже обработанные подзапросы rtable и cteList. RLS-политики применяются последними — при добавлении новых quals с подлинками query_tree_walker мог бы обойти их повторно.

Инвалидация планов и кэш

Запрос помечается как содержащий row security присваиванием *hasRowSecurity = true. Это нужно, чтобы plancache мог инвалидировать план при необходимости, например при смене роли. При изменении политик инвалидация выполняется через CacheInvalidateRelcacheByTuple(reltup) для кортежа отношения, которому принадлежит политика, — это заставляет переделать зависимые планы. Если отношение уже удалено (гонка), ничего не делается.

При удалении политики по идентификатору инвалидация выполняется вызовом CacheInvalidateRelcache(rel). Комментарий поясняет смысл флага relrowsecurity: он не просто указывает на наличие политик — при его установке пользователем весь доступ к отношению должен идти через политику, а при отсутствии политик создаётся default-deny политика, фильтрующая все записи кроме запросов владельца.

Роли, BYPASSRLS и суперпользователи

Функция check_enable_rls сначала проверяет has_bypassrls_privilege(user_id): если у пользователя есть право BYPASSRLS, возвращается RLS_NONE_ENV. Суперпользователи всегда считаются обладающими BYPASSRLS, поэтому для них RLS также не применяется. Далее проверяется владение таблицей через object_ownercheck: владелец обычно обходит RLS, но если на таблице установлен FORCE ROW LEVEL SECURITY и это не проверка ссылочной целостности (InNoForceRLSOperation() ложно), возвращается RLS_ENABLED — статус, означающий, что RLS включено и политики применяются (в отличие от RLS_NONE, когда RLS на таблице нет вовсе, и RLS_NONE_ENV, когда RLS не применяется, но решение зависит от окружения, например от текущего пользователя).

Если RLS всё же должно применяться, но GUC row_security выключен и noError ложно, выбрасывается ошибка ERRCODE_INSUFFICIENT_PRIVILEGE. Функции row_security_active и row_security_active_name вызывают check_enable_rls с noError=true и возвращают булево rls_status == RLS_ENABLED. В rowsecurity.c флаг BYPASSRLS напрямую не проверяется: там формируются политики по типу команды и правам, а решение о включении RLS принимается в check_enable_rls.

Пограничные случаи

Проверки ссылочной целостности — уникальные и первичные ключи, внешние ключи — всегда обходят RLS, чтобы сохранить целостность данных. При разработке схем и политик нужно остерегаться «скрытых каналов» утечки информации через такие проверки. В коде это реализовано так: если пользователь является владельцем таблицы, то при отсутствии FORCE ROW LEVEL SECURITY или при нахождении в контексте InNoForceRLSOperation возвращается RLS_NONE_ENV. Комментарий прямо поясняет, что InNoForceRLSOperation означает «не применять RLS даже если на таблице установлен FORCE RLS — если текущий пользователь является владельцем», и это сделано именно для того, чтобы проверки ссылочной целостности могли корректно выполняться. Такое решение принимается только после проверки, что пользователь — владелец таблицы, что всегда верно для проверок ссылочной целостности.

Отдельный сценарий — UPDATE + SELECT FOR UPDATE в READ COMMITTED. Если alice в одной транзакции меняет группу mallory и содержимое строки information, затем коммитит, а mallory параллельно выполняет SELECT * FROM information WHERE group_id = 2 FOR UPDATE, её транзакция может дойти до строки information сразу после транзакции alice: она блокируется в ожидании коммита, а затем благодаря FOR UPDATE получает обновлённое содержимое строки. Однако обновлённая строка для неявного SELECT из users не читается, потому что у этого подзапроса нет FOR UPDATE; строка users читается по снимку, взятому в начале запроса. Поэтому выражение политики проверяет старое значение уровня привилегий mallory и разрешает ей увидеть обновлённую строку. RLS не гарантирует, что политика увидит согласованные с обновлением данные о привилегиях.

Хуки для расширений

Хуки существуют, чтобы расширения могли добавлять собственные политики безопасности строк. row_security_policy_hook_permissive добавляет политики, объединяемые с другими разрешающими через OR, а row_security_policy_hook_restrictive — политики, применяемые независимо от остальных и объединяемые через AND. Тип хука — функция, принимающая тип команды и отношение и возвращающая список:

typedef List *(*row_security_policy_hook_type) (CmdType cmdtype,
												Relation relation);

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

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

  • Политики добавляются после парсинга, поэтому любой подзапрос внутри USING или WITH CHECK проходит rewrite заново — именно здесь и замыкается рекурсия. Защита от неё — проверка activeRIRs по OID отношения; ошибка «infinite recursion detected in policy for relation» означает, что политика на таблице прямо или косвенно ссылается на саму эту таблицу.
  • Restrictive-политики сортируются по имени, и встроенные всегда проверяются раньше добавленных хуком. Порядок применения WithCheckOptions предсказуем, но зависит от имён политик.
  • Отсутствие хотя бы одной permissive-политики для команды означает default-deny: запись не пройдёт ни при каких условиях. Это не ошибка конфигурации, а заданное поведение.
  • Если пользователь имеет UPDATE, но не имеет SELECT, SELECT-политики не добавляются, и запрос без RETURNING отработает. Но при RETURNING права SELECT требуются, и новые строки должны удовлетворять SELECT-политикам, иначе будет ошибка.
  • Проверки ссылочной целостности обходят RLS всегда — это осознанное решение ради целостности данных, но оно создаёт потенциальный канал утечки информации, который нужно учитывать при проектировании политик.
  • В READ COMMITTED политика может увидеть устаревшие данные о привилегиях: подзапрос без FOR UPDATE читает строку по снимку начала запроса, тогда как основная строка уже обновлена. RLS не даёт гарантий согласованности между проверкой политики и обновляемыми данными.

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

Источники

Похожее