АйТи Фреш
Главная / Статьи / 1С и базы данных
1С и базы данных

RLS работает на таблице, но через view видны чужие строки: security_invoker и права, которые придётся выдать

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~23 мин чтения
RLS работает на таблице, но через view видны чужие строки: security_invoker и права, которые придётся выдать
Иллюстрация к статье «RLS работает на таблице, но через view видны чужие строки: security_invoker и права, которые придётся выдать».

Прямой SELECT из таблицы честно режет чужие строки, а тот же запрос через представление отдаёт всю базу целиком. Разработчик смотрит на политику RLS, политика правильная, и непонятно, где утечка. Я разберу, почему так устроено в PostgreSQL 18, как это чинится одним параметром view, почему сразу после починки прилетает permission denied на исходную таблицу, и какие права надо выдать, чтобы всё встало на место. Плюс покажу разбор из практики: в танцевальной студии «Танцпол-стиль» такая ошибка полтора месяца показывала администраторам одного зала оплаты и телефоны клиентов других залов.

Кто на самом деле смотрит в таблицу, когда вы делаете SELECT из view

Картина всегда одна и та же. На таблице включён RLS, политика написана аккуратно, разработчик заходит под ролью приложения, делает SELECT напрямую — видит только свои строки. Радуется. Потом тот же самый фильтр уезжает в отчёт, отчёт ходит не в таблицу, а в представление, и представление отдаёт всё. Первая реакция — «RLS сломался». RLS не сломался. Он просто применяется не к тому пользователю, о котором вы думаете.

В PostgreSQL права на базовые отношения обычного view проверяются для владельца представления, а не для того, кто выполняет запрос. Документация формулирует это прямо: «By default, access to the underlying base relations referenced in the view is determined by the permissions of the view owner». То есть view — это легальный, задокументированный и, вообще-то, очень полезный механизм делегирования прав: вы даёте человеку SELECT на представление и не даёте на таблицу. С RLS этот же механизм превращается в дыру, потому что политики берутся тоже владельца представления.

Дальше срабатывает вторая шестерёнка. Владелец view почти всегда совпадает с владельцем таблицы — это та роль, под которой катились миграции. А владелец таблицы штатно обходит row-level security: «Table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY». Складываем два факта — и получаем ровно то, что видит разработчик: политики оценены для владельца, владелец их обходит, представление честно отдаёт все 900 тысяч строк.

Отсюда два независимых рычага, и оба надо дёрнуть. Первый — на таблице: FORCE ROW LEVEL SECURITY, чтобы владелец перестал быть исключением. Второй — на представлении: security_invoker = true, чтобы политики и права считались для вызывающего. Одного второго рычага обычно хватает, но первый я всё равно включаю: он закрывает случай, когда кто-то ходит в таблицу напрямую под ролью миграций.

Не путайте view без security_invoker с функцией SECURITY DEFINER. Документация подчёркивает: CURRENT_USER, вызванный прямо в теле представления, всегда вернёт вызывающего пользователя, и на это security_invoker не влияет. Значит, тело view может выглядеть абсолютно правильным (WHERE branch_id = current_setting('app.branch_id')), а политика на таблице при этом оцениваться для совсем другой роли.

Разбор: «Танцпол-стиль», четыре зала и отчёт, который показывал чужих клиентов

Клиент — танцевальная студия «Танцпол-стиль»: четыре зала в разных районах, 41 рабочее место, из них администраторы на ресепшенах, тренеры и небольшая бухгалтерия. Запись на занятия и продажа абонементов идут через самописную CRM на PostgreSQL 18 (минорные версии накатываем в квартальное окно), данные залов разделены по branch_id. Схема app, таблица app.payments — оплаты и списания абонементов за семь лет, около 900 тыс. строк, app.branches — справочник залов. Пришли с формулировкой «администратор зала на Академической видит в сводном отчёте клиентов других залов, но не всегда». Классика: чужие строки попадали только в отчёты за длинный период, а за день администраторы их просто не замечали.

Роли были разложены нормально: app_owner — владелец схемы и всех объектов, под ним катятся миграции; app_api — роль пула соединений CRM, LOGIN, без BYPASSRLS. Приложение в начале каждой транзакции делает SET LOCAL app.branch_id по залу, к которому привязан администратор. RLS включён, политика на месте, выглядела она так.

ALTER TABLE app.payments ENABLE ROW LEVEL SECURITY;

CREATE POLICY branch_isolation ON app.payments
  FOR ALL TO app_api
  USING      (branch_id = current_setting('app.branch_id', true)::uuid)
  WITH CHECK (branch_id = current_setting('app.branch_id', true)::uuid);

GRANT SELECT, INSERT, UPDATE, DELETE ON app.payments TO app_api;

Проверка под app_api через прямой SELECT давала честные 230 тыс. строк своего зала вместо 900 тыс. А отчёт ходил в app.v_payments_summary — обычное представление, созданное миграцией, то есть принадлежащее app_owner. Владелец таблицы, FORCE RLS не включён, security_invoker не задан. Представление отдавало всё. Мы прогнали инвентаризацию по базе: из 9 представлений над таблицами с RLS security_invoker стоял ровно у двух (их разработчик делал руками уже на PostgreSQL 15+), у 7 не стоял. Три из этих семи были выведены в кабинет администратора, то есть реально показывали людям ФИО и телефоны клиентов чужих залов — а это уже персональные данные, а не просто неудобство.

Починка заняла минут сорок, и почти всё время ушло не на сам параметр, а на права: после включения security_invoker отчёт лёг с permission denied for table payments, потому что раньше app_api про таблицу вообще ничего не знал — ему хватало SELECT на view. Следом всплыл справочник app.branches, который представление подтягивало JOIN-ом: SELECT на него у app_api тоже не было. По итогу: 7 представлений переведены на security_invoker, выдано 5 GRANT SELECT на базовые таблицы и справочники плюс USAGE на схему, на двух таблицах включён FORCE ROW LEVEL SECURITY с отдельной политикой для app_owner. Утечка жила с момента выкатки отчёта — по истории миграций полтора месяца.

Если данные разных залов, филиалов или клиентов лежат в одной базе и разделены RLS, а представления создавались миграциями, — считайте, что дыра у вас уже есть, пока не доказано обратное. Проверяется одним запросом (он ниже), занимает минуту.
RLS работает на таблице, но через view видны чужие строки: security_invoker и права, которые придётся выдать — схема
Схема к статье. Открыть схему в полном размере

Как включить security_invoker правильно

Параметр появился в PostgreSQL 15 — в релиз-нотах он записан как «Allow table accesses done by a view to optionally be controlled by privileges of the view's caller (Christoph Heiss)», и там же прямо сказано: «Previously, view accesses were always treated as being done by the view's owner. That's still the default». Обратите внимание на «That's still the default» — в 18-й версии поведение по умолчанию не изменилось, никакого автоматического ужесточения при апгрейде не произошло. Если вы приехали на 18 с 13-й или 14-й, все ваши старые представления по-прежнему работают от имени владельца.

Задаётся он двумя способами — при создании и на живом объекте. На живом объекте меняются только метаданные, перестройки данных нет, но ALTER VIEW берёт на представление блокировку и будет ждать, пока закончится тяжёлый отчёт, который его сейчас читает, — а новые запросы к view встанут в очередь за ним. Я делаю такие правки в окно и с lock_timeout, чтобы не поймать очередь.

-- при создании
CREATE VIEW app.v_payments_summary
  WITH (security_invoker = true, security_barrier = true) AS
SELECT branch_id, date_trunc('month', created_at) AS m, count(*), sum(amount)
FROM app.payments
GROUP BY 1, 2;

-- на существующем
SET lock_timeout = '3s';
ALTER VIEW app.v_payments_summary SET (security_invoker = true);

-- вернуть как было
ALTER VIEW app.v_payments_summary RESET (security_invoker);

Порядок действий, который я держу как шаблон, чтобы не уронить прод посреди правки: сначала выдать вызывающей роли права на базовые отношения и функции, потом переключить представление, потом проверить под ролью, потом включать FORCE RLS на таблицах. Если сделать наоборот — переключить view раньше, чем выданы GRANT, — вы получите отчёт, падающий с permission denied, ровно между двумя командами, и обязательно именно в этот момент кто-нибудь нажмёт «Сформировать».

Отдельно про FORCE ROW LEVEL SECURITY: после него владелец таблицы подчиняется политикам наравне со всеми. Если политика написана только TO app_api, для app_owner политик нет вовсе, и срабатывает default-deny — миграция с UPDATE по данным молча обновит ноль строк, а INSERT упадёт на проверке политики. Поэтому вместе с FORCE я сразу добавляю владельцу явную политику (например, отдельную CREATE POLICY ... TO app_owner USING (true)) или гоняю миграции данных под отдельной ролью, которой такая политика выдана осознанно.

security_invoker распространяется вглубь: если базовое отношение — само security-invoker представление, оно проверит свои таблицы правами текущего пользователя, даже когда обращение к нему идёт из обычного view. Обратное неверно. Значит, в цепочке view → view → таблица недостаточно переключить только верхний.
Порядок действий: Как включить security_invoker правильно — схема
Порядок действий: Как включить security_invoker правильно. Открыть схему в полном размере

Permission denied после переключения: какие права выдать и почему их стало больше

Это второй акт спектакля, и он пугает людей сильнее первого. Только что отчёт показывал лишнее — теперь он не показывает ничего и падает с ERROR: permission denied for table payments. Ощущение, что security_invoker всё сломал. На самом деле он ровно это и должен делать: документация прямым текстом требует, чтобы «the user of a security invoker view must have the relevant permissions on the view and its underlying base relations». Раньше прав на таблицу не было и не требовалось — их подставлял владелец. Теперь требуется.

Сколько именно прав выдать — вопрос, где начинается расхождение вкусов. Минималистичный подход: только SELECT и только на те таблицы, что реально участвуют в представлении. Ленивый подход: GRANT SELECT ON ALL TABLES IN SCHEMA app и забыть. Я за первый, но с оговоркой: без ALTER DEFAULT PRIVILEGES вы будете доставлять права руками после каждой миграции, которая создаёт таблицу. Поэтому у меня всегда есть строка про дефолтные привилегии.

GRANT USAGE ON SCHEMA app TO app_api;
GRANT SELECT ON app.payments, app.branches TO app_api;
GRANT EXECUTE ON FUNCTION app.fmt_period(date, date) TO app_api;

-- чтобы не бегать за каждой новой таблицей
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
  GRANT SELECT ON TABLES TO app_api;

Отдельно — обновляемые представления. Если через view идут INSERT/UPDATE/DELETE, вызывающей роли нужны эти же права на базовой таблице, а не только SELECT. И политика должна иметь WITH CHECK, иначе администратор одного зала сможет записать оплату с чужим branch_id: USING фильтрует то, что видно, WITH CHECK — то, что записывается. Если WITH CHECK не задан явно, он неявно повторяет USING — это удобно и в 90 % случаев то, что нужно, но полагаться на неявное поведение в коде, отвечающем за изоляцию данных, я не советую. Пишите обе части руками.

И проверяйте не глазами, а функцией. has_table_privilege врёт реже, чем память разработчика, а список того, что представление реально трогает, лучше брать из pg_depend, а не из чтения SQL-текста.

Соблазн выдать роли BYPASSRLS «чтобы отчёты просто работали» надо давить сразу. Это не обход одной проблемы, это выключение RLS для роли целиком и навсегда, на всех таблицах, включая те, которые вы добавите через год.

Ловушки, на которые я натыкался: барьер, утечки через функции, вложенность и pg_dump

Первая: security_invoker сам по себе не делает представление безопасным барьером. Это разные параметры про разные вещи. Барьерное view защищает от того, что планировщик протащит дешёвую пользовательскую функцию внутрь и вычислит её на строках, которые вы хотели скрыть. В документации есть каноничный пример с функцией tricky и COST 0.0000000000000000000001: «Every person and phone number in the phone_data table will be printed as a NOTICE, because the planner will choose to execute the inexpensive tricky function before the more expensive NOT LIKE». Если через ваше представление ходят пользователи, которые могут создавать свои функции, — ставьте security_barrier = true. Если в базу ходит только ваше приложение с фиксированным набором запросов, риск сильно преувеличен, и я бы не стал платить за него планами.

Потому что платить придётся. Документация прямо пишет, что индексный скан не может быть выбран для запросов к барьерным представлениям и таблицам с политиками RLS, если оператор из WHERE относится к семейству операторов индекса, но его функция не помечена LEAKPROOF; и что барьерные view «may perform far worse». Большинство простых операторов равенства уже LEAKPROOF, поэтому проблемы начинаются на пользовательских функциях и приведениях типов в условиях. У «Танцпол-стиля» после включения барьера на одном из отчётов выборка оплат за период ушла с 40 мс на 1,4 с — лечилось не отменой защиты, а тем, что условие по дате переписали без пользовательской функции-обёртки, и планировщик снова смог взять индекс.

Вторая ловушка — вложенные представления, о ней я уже сказал выше, но повторю, потому что именно на ней ловятся чаще всего: переключили верхнее view, проверили — работает, а промежуточное осталось обычным, и данные снова текут через него. Проверять надо по всей цепочке зависимостей, а не по одному объекту.

Третья — материализованные представления. Там параметра security_invoker нет вовсе: CREATE MATERIALIZED VIEW принимает storage-параметры от CREATE TABLE, и в этот список security_invoker не входит. Матвью — это, по сути, таблица со снимком данных, посчитанным при REFRESH. Если вам нужно разное содержимое для разных филиалов или клиентов, стройте RLS-политику на самом матвью-подобном хранилище (обычной таблице с branch_id) или не кладите в снимок то, что нельзя показывать всем.

Четвёртая — pg_dump и логика «выгрузим и посмотрим». Есть параметр row_security, по умолчанию on; когда он off, «queries fail which would otherwise apply at least one policy», и pg_dump выставляет его в off по умолчанию именно для того, чтобы не выгрузить молча половину таблицы. То есть если у вас бэкап делает роль без BYPASSRLS, дамп таблицы с политиками не «тихо усечётся» — он честно упадёт с ошибкой. Это хорошее поведение, но оно ломает наивные скрипты выгрузок, написанные до включения RLS. И обратите внимание на связку с FORCE: пока FORCE не включён, владелец таблицы обходит политики и его дамп проходит; после FORCE дамп под владельцем тоже упадёт. На сам row_security суперпользователи и роли с BYPASSRLS не реагируют — поэтому бэкапы обычно и делают под отдельной ролью с BYPASSRLS, которую не используют ни для чего другого.

Не помечайте свои функции LEAKPROOF, чтобы «вернуть индексный скан». Это утверждение под подпись, что функция не даёт утечь ничему о невидимых строках — ни через результат, ни через сообщение об ошибке. Ошибётесь — получите ровно ту утечку, от которой строили RLS, только теперь с индексом и быстро.
Памятка: Ловушки, на которые я натыкался: барьер, утечки через функции, вложенность и pg_dump — схема
Памятка: Ловушки, на которые я натыкался: барьер, утечки через функции, вложенность и pg_dump. Открыть схему в полном размере

Как я это аудирую и как не даю сломаться обратно

Разовая починка ничего не стоит, если через два спринта миграция создаст новое представление без параметра. Поэтому у меня на такие базы всегда два артефакта: запрос-инвентаризация и регресс-тест, который гоняется в CI на каждой ветке. Инвентаризация отвечает на вопрос «где сейчас дыра», тест — «не появилась ли новая».

Инвентаризация ищет представления, которые опираются на таблицы с включённым RLS и при этом не имеют security_invoker. Опции представления лежат в pg_class.reloptions, зависимости — в pg_depend; читать текст view регулярками не надо, это ненадёжно. Учтите ограничение: запрос ниже видит только прямые зависимости, поэтому представление поверх другого представления, которое уже смотрит в RLS-таблицу, нужно проверять вторым проходом по найденным view.

SELECT DISTINCT v.oid::regclass AS view_name,
       c.oid::regclass AS base_table,
       coalesce(array_to_string(v.reloptions, ','), '(нет опций)') AS opts
FROM pg_class v
JOIN pg_rewrite r ON r.ev_class = v.oid
JOIN pg_depend d ON d.objid = r.oid AND d.refclassid = 'pg_class'::regclass
JOIN pg_class c ON c.oid = d.refobjid AND c.relkind IN ('r','p')
WHERE v.relkind = 'v'
  AND c.relrowsecurity            -- на базовой таблице включён RLS
  AND c.oid <> v.oid
  AND NOT EXISTS (SELECT 1 FROM pg_options_to_table(v.reloptions) o
                  WHERE o.option_name = 'security_invoker'
                    AND lower(o.option_value) IN ('true','on','1','yes'))
ORDER BY 1;

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

SET ROLE app_api;
SET app.branch_id = '11111111-1111-1111-1111-111111111111';
SELECT count(*) = 1 AS ok_view  FROM app.v_payments_summary;
SELECT count(*) = 1 AS ok_table FROM app.payments;
RESET ROLE;

И ещё одна вещь, которую я делаю до всего остального, когда прихожу в чужую базу: проверяю, кто вообще обходит RLS. Суперпользователи и роли с BYPASSRLS видят всё, и никакие security_invoker их не касаются. Довольно часто оказывается, что приложение годами ходит в базу под ролью с rolsuper или rolbypassrls, и тогда обсуждать политики бессмысленно — сначала надо развести роли.

Регресс-тест на изоляцию арендаторов важнее любой документации по правам. Права меняются молча — миграцией, GRANT-ом из чата, новой ролью для BI. Тест — единственное, что об этом сообщит.

Что делать в первую очередь, а на что можно забить

Если у вас в одной базе живут данные нескольких филиалов или клиентов, разделённые RLS, и вы читаете это с мыслью «надо будет посмотреть» — не надо откладывать. Порядок приоритетов такой. Первое и срочное: прогнать запрос-инвентаризацию и посмотреть, есть ли представления над RLS-таблицами без security_invoker, которые доступны наружу через API или BI. Это буквально минута работы, и это единственный шаг, который может выявить активную утечку прямо сейчас. Второе: снять BYPASSRLS и суперюзера с ролей приложения — без этого остальное декоративно. Третье: включить FORCE ROW LEVEL SECURITY на таблицах с политиками, чтобы владелец перестал быть исключением из правил, — не забыв дать ему явную политику, иначе встанут миграции.

Что можно отложить без угрызений совести. security_barrier на представлениях, в которые ходит только ваш бэкенд с фиксированным набором запросов, — риск реальный, но эксплуатируется он только тем, кто может выполнять произвольный SQL и создавать функции. Если такого доступа у пользователей нет, ставьте барьер плановой задачей, а не ночным хотфиксом. Разбор LEAKPROOF-операторов и охоту за индексными сканами — тоже потом, сначала корректность, потом производительность. Переписывание представлений на функции SECURITY DEFINER с ручной проверкой — почти всегда лишнее усложнение, и я его не рекомендую.

И честно про спорное. RLS — не универсальный ответ на изоляцию арендаторов. Он даёт настоящую защиту на уровне СУБД, но стоит планов, усложняет отладку (запрос «не видит» строку, и вы полчаса ищете, почему) и требует дисциплины в правах. Есть команды, которым дешевле развести арендаторов по схемам или по базам — и они правы для своего масштаба. Но если вы уже выбрали RLS в одной таблице с branch_id, то делайте его целиком: политика на таблице без разбора представлений даёт ложное чувство защищённости, а это хуже, чем честное её отсутствие. Потому что от отсутствия защиты вы хотя бы не строите отчёты для клиентов.

Последнее наблюдение из практики. Ошибка с представлениями почти никогда не рождается вместе с системой — её приносит третий-четвёртый релиз, когда «нужен быстрый отчёт», а отчёт удобнее сделать view поверх готовых таблиц. RLS писал один человек полгода назад, view делает другой сегодня, и связь между ними нигде не зафиксирована. Так что помимо параметра в базе нужна ещё одна строчка в чек-листе ревью миграций: «создаёт ли этот PR представление над таблицей с RLS, и стоит ли там security_invoker». Стоит дешевле любого инцидента.

Проверка занимает минуту, починка — вечер, а инцидент с чужими персональными данными — это разговор с клиентами, чьи телефоны увидели посторонние, и возможные вопросы по 152-ФЗ. Соотношение того стоит.
Порядок действий: Что делать в первую очередь, а на что можно забить — схема
Порядок действий: Что делать в первую очередь, а на что можно забить. Открыть схему в полном размере

Частые вопросы

С какой версии PostgreSQL доступен security_invoker?

С PostgreSQL 15 — параметр добавлен в CREATE VIEW и ALTER VIEW. В релиз-нотах 15.0 это записано в разделе Privileges с оговоркой, что прежнее поведение (доступ от имени владельца представления) остаётся значением по умолчанию. В PostgreSQL 18 умолчание не изменилось: при апгрейде старые представления продолжат работать правами владельца, автоматического ужесточения не будет.

Почему после включения security_invoker появился permission denied for table?

Потому что раньше права на базовую таблицу подставлял владелец представления, а теперь они проверяются у того, кто выполняет запрос. Документация требует, чтобы пользователь security-invoker view имел права и на само представление, и на базовые отношения. Выдайте роли GRANT USAGE на схему, GRANT SELECT (и DML, если view обновляемое) на базовые таблицы и проверьте EXECUTE на функции из тела представления — это требование действует и для обычных view, но его легко потерять после REVOKE ... FROM PUBLIC.

Достаточно ли включить security_invoker, или нужен ещё FORCE ROW LEVEL SECURITY?

Одного security_invoker хватает, чтобы закрыть путь через представление. Но владелец таблицы по-прежнему обходит RLS при прямом обращении, поэтому FORCE ROW LEVEL SECURITY я включаю всегда (вместе с явной политикой для роли миграций, иначе для неё сработает default-deny) — это закрывает случай, когда в базу ходят под ролью миграций, скриптом обслуживания или из BI под административной учёткой. Суперпользователя и роли с BYPASSRLS это не остановит — их надо просто не использовать для приложения.

Нужно ли одновременно ставить security_barrier?

Только если пользователи могут выполнять произвольный SQL и создавать свои функции: барьер защищает от того, что планировщик вычислит дешёвую вредоносную функцию раньше фильтра представления и та утечёт значения через NOTICE или текст ошибки. Если в базу ходит исключительно ваше приложение с фиксированным набором запросов, риск заметно преувеличен, а плата в планах реальна — барьер и RLS мешают выбрать индексный скан, когда операторы не помечены LEAKPROOF.

Работает ли security_invoker для материализованных представлений?

Нет. CREATE MATERIALIZED VIEW принимает storage-параметры из CREATE TABLE, и security_invoker в их числе нет. Матвью — это снимок данных, посчитанный при REFRESH, и содержимое снимка одинаково для всех, кто имеет на него SELECT. Если нужна изоляция по филиалам или клиентам, кладите снимок в обычную таблицу с branch_id и вешайте политику RLS уже на неё.

Как быстро проверить всю базу, а не по одному представлению?

Запросом по системным каталогам: соединить pg_class (relkind = 'v') с pg_rewrite и pg_depend, отобрать те представления, чьи базовые таблицы имеют relrowsecurity = true, и отфильтровать те, у кого в reloptions нет security_invoker=true. Готовый запрос есть в разделе про аудит. Разбирать текст view регулярками не стоит — зависимости надёжнее брать из каталога.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи