PostgreSQL Row Level Security: как настроить RLS для мультиорганизационных приложений, написать политики и роли, проверить запросы и не сломать админку.

PostgreSQL Row Level Security (RLS) - это правила в базе, которые решают, какие строки таблицы можно видеть и менять. Это удобно для приложений с организациями (мультиарендность): защита живет рядом с данными и не зависит от того, насколько аккуратно вы написали каждый запрос.
После включения RLS часто кажется, что система «сломалась». Причина простая: база по умолчанию перестает доверять запросам. Если политика не разрешила строку, вы ее не увидите. Если политика не разрешила изменение, INSERT/UPDATE/DELETE не пройдет, даже если в приложении у пользователя «админская» роль.
Сильнее всего страдают админка и фоновые задачи. Админка нередко ходит в базу тем же пользователем, что и обычное приложение, просто с другим интерфейсом. А фоновые джобы (письма, пересчеты, синхронизации) часто запускаются без «контекста организации» и внезапно теряют доступ почти ко всем строкам.
Типичные симптомы:
Почему недостаточно просто «фильтровать org_id в WHERE»? Потому что это легко забыть (особенно в редких запросах, отчетах, админских ручках). И это не защищает от прямых запросов к базе или от ошибок в коде. RLS делает так, что даже если кто-то случайно написал SELECT без WHERE, база все равно не отдаст чужие строки.
Простой пример: менеджер в CRM открывает «Клиенты» и видит пусто, потому что приложение не установило текущую org_id в сессию. Или админ пытается исправить запись в другой организации, но политика разрешает доступ только к «своим» строкам, и правка не проходит.
RLS проще всего держать в голове, когда у каждой строки есть понятный ответ на вопрос: к какой организации она относится. Тогда политика почти всегда сводится к одному условию по org_id, а не к набору исключений.
Минимальный набор таблиц для мультиорганизационного приложения обычно такой:
organizations(id, name, ...)users(id, email, ...)memberships(user_id, org_id, role, status, ...)org_id (например, customers, deals, invoices, notes)Ключевая идея: источник прав - это не поле в users, а отдельная таблица memberships. Пользователь может состоять в нескольких организациях, временно терять доступ, иметь разные роли. Все это удобно хранить в одном месте и использовать в политиках через проверку существования membership.
Для доменных таблиц правило простое: добавляйте org_id везде, где данные принадлежат организации, и делайте внешний ключ на organizations(id). Не рассчитывайте, что «и так понятно через связь». Когда org_id не лежит прямо в строке, политики быстро превращаются в сложные подзапросы, а ошибки становятся тихими и дорогими.
Пример: в CRM сделка (deals) и клиент (customers) оба имеют org_id. Тогда правило понятное: сделку можно видеть, если у пользователя есть membership в этой организации. А вот если deals ссылается на customers, но не имеет org_id, политика для сделок начнет зависеть от таблицы клиентов и может неожиданно ломать UPDATE или INSERT.
Отдельная тема - общие справочники. Если данные действительно общие для всех (например, список стран), не добавляйте туда org_id и не включайте RLS. Если справочник «почти общий», но допускает переопределение (например, статусы сделок), проще разделить на глобальные значения без org_id и кастомные значения с org_id. Так права остаются очевидными.
RLS работает предсказуемо, когда роли в базе разделены по задачам. Если одна и та же роль и читает данные, и делает миграции, и «иногда админит», вы получите смесь: часть запросов внезапно проходит без нужных ограничений, а часть падает с ошибками.
Обычно хватает четырех ролей:
Самая частая ошибка - подключаться к базе из приложения суперпользователем. Тогда любая уязвимость в приложении превращается в полный доступ ко всем данным, а отладка политик становится бессмысленной.
Похожая ловушка - BYPASSRLS. Технически это удобно: роль видит строки вне политик. Практически в проде это почти всегда плохая идея: любой баг в коде, который случайно выполняется под такой ролью, превращает «админку» в дыру. Безопаснее дать админской роли ровно то, что нужно, через отдельные политики.
Еще одно правило, которое сильно снижает риск сюрпризов: роль приложения не должна владеть таблицами. Владелец объекта может менять его и часто получает дополнительные возможности, которые вы не планировали.
Начните с простой таблицы, где каждая строка принадлежит организации. Для примера возьмем public.customers с колонкой org_id.
ENABLE ROW LEVEL SECURITY включает проверку политик для обычных запросов. Но владельцы таблицы и роли с правами обхода могут неожиданно видеть все. FORCE ROW LEVEL SECURITY заставляет применять политики почти ко всем, включая владельца таблицы. Для мультиарендности это обычно то, что нужно.
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customers FORCE ROW LEVEL SECURITY;
Политика чтения отвечает за то, какие строки вообще видны. Частая практика: хранить текущую организацию в параметре сессии и сравнивать с org_id.
CREATE POLICY customers_select_by_org
ON public.customers
FOR SELECT
USING (
org_id = NULLIF(current_setting('app.org_id', true), '')::uuid
);
Если app.org_id не установлен, current_setting(..., true) вернет NULL, сравнение не пройдет, и строки не покажутся.
USING фильтрует строки для SELECT, UPDATE, DELETE. А WITH CHECK проверяет новые значения при INSERT и после UPDATE. Если сделать USING и забыть WITH CHECK, вставки и изменения либо начнут падать, либо позволят записать строку «не в ту организацию».
CREATE POLICY customers_insert_by_org
ON public.customers
FOR INSERT
WITH CHECK (
org_id = NULLIF(current_setting('app.org_id', true), '')::uuid
);
CREATE POLICY customers_update_by_org
ON public.customers
FOR UPDATE
USING (
org_id = NULLIF(current_setting('app.org_id', true), '')::uuid
)
WITH CHECK (
org_id = NULLIF(current_setting('app.org_id', true), '')::uuid
);
CREATE POLICY customers_delete_by_org
ON public.customers
FOR DELETE
USING (
org_id = NULLIF(current_setting('app.org_id', true), '')::uuid
);
Проверьте два случая: без app.org_id сделать SELECT (должно вернуться 0 строк) и попробовать INSERT (должна быть ошибка из-за WITH CHECK). Это быстро показывает, что «по умолчанию закрыто», а доступ появляется только при правильном контексте.
Политики RLS почти всегда завязаны на org_id. Важно не только где хранить контекст, но и как передавать его так, чтобы значение не «прилипало» к соединению и не протекало между пользователями.
Практичный способ - хранить org_id в параметре сессии (custom GUC) и читать его из политики через current_setting. В приложении выставляйте его через set_config.
BEGIN;
SELECT set_config('app.org_id', '42', true);
-- любые запросы в рамках транзакции
SELECT * FROM deals;
COMMIT;
Третий аргумент true делает это эквивалентом SET LOCAL: настройка действует только внутри текущей транзакции и автоматически очищается после COMMIT или ROLLBACK. Это особенно важно при пуле соединений.
Проблема пула в том, что одно и то же физическое соединение может обслужить разных пользователей подряд. Если вы сделали обычный SET app.org_id = ... вне транзакции, следующий запрос на этом же соединении может «унаследовать» чужой org_id.
В Go это часто проявляется с database/sql: соединения переиспользуются, а контекст выставлен один раз «где-то при логине». Безопаснее привязать установку org_id к транзакции и делать это в начале обработки запроса.
Админка обычно ломается в момент, когда RLS начинает честно скрывать строки. Самый простой выход (отключать RLS или подключаться суперпользователем) опасен: вы теряете смысл защиты.
Надежный подход - добавить отдельное правило, которое разрешает доступ админам, но только при явном признаке. Признак можно брать из роли (членство в роли) или из параметра сессии, который выставляет сервер приложения после авторизации.
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
-- Обычный доступ: только своя организация
CREATE POLICY customers_by_org
ON public.customers
USING (org_id = current_setting('app.org_id')::uuid)
WITH CHECK (org_id = current_setting('app.org_id')::uuid);
-- Админский просмотр всех организаций (только когда флаг выставлен)
CREATE POLICY customers_admin_all
ON public.customers
FOR SELECT
USING (current_setting('app.is_admin', true) = 'true');
Важно: флаг в сессии должен выставлять только сервер приложения, а не клиент. И выставлять его лучше локально на транзакцию, чтобы он не «прилип» к соединению.
Иногда админке нужны действия, которые трудно выразить политиками: массовый перенос записей между организациями, восстановление, сложные проверки. Тогда помогает функция с SECURITY DEFINER, но только если вы жестко ограничите ее:
search_path внутри функцииДаже админке обычно достаточно частичных прав. Часто хорошо работает разделение:
Так админка продолжит работать, RLS останется включенным, а обходы будут явными и проверяемыми.
Проверять RLS удобнее прямо в psql (или любом SQL-клиенте), имитируя поведение приложения: переключаем роль и задаем org_id в сессии. Это быстрее, чем угадывать по логам, и сразу показывает, где доступ режется.
BEGIN;
SET LOCAL ROLE app_user;
SET LOCAL app.org_id = '11111111-1111-1111-1111-111111111111';
SELECT count(*) FROM customers;
INSERT INTO customers (org_id, name) VALUES (current_setting('app.org_id')::uuid, 'Тест');
UPDATE customers SET name = 'Тест2' WHERE name = 'Тест';
DELETE FROM customers WHERE name = 'Тест2';
ROLLBACK;
Чтобы проверка была системной, держите небольшой набор кейсов на CRUD для каждой роли (пользователь, менеджер, админ) и прогоняйте его после изменений политик или ролей. Минимум, который стоит проверить: чтение «своих» строк, попытка прочитать чужой org_id, вставка с чужим org_id, обновление чужой строки, удаление.
Как читать ошибки: permission denied for table ... обычно означает, что не хватает GRANT на таблицу или схему (RLS тут ни при чем). А ERROR: new row violates row-level security policy почти всегда указывает на WITH CHECK.
RLS часто «ломает» систему не из-за одного неправильного условия, а из-за мелочей вокруг.
Есть USING, но нет WITH CHECK. Выборки работают, но после вставки запись «пропадает» или вставка начинает падать.
UPDATE разрешен, но org_id можно менять. Тогда строку можно «перекинуть» в чужую организацию. На практике это выглядит как пропажа данных.
RLS включен не везде. Вы закрыли одну таблицу, но забыли про дочернюю, связующую или «не очень важный справочник». В одном месте это дает утечки через join, в другом - внезапные пустые результаты.
Админка и сервисные роли запускаются без контекста. В итоге «все пусто», хотя данные на месте.
Если после включения RLS падают миграции и фоновые джобы, почти всегда причина в том, что они делают UPDATE/DELETE без нужных прав и без контекста.
Перед релизом полезно прогнать короткую проверку. Она ловит ситуации, когда RLS вроде настроен правильно, но ломает админку, импорты или фоновые задачи.
По схеме и политикам:
По контексту и админским сценариям:
Финальный быстрый тест: под каждой ролью выполните 3-4 запроса - чтение своих строк, попытка прочитать чужой org_id, вставка с чужим org_id, обновление чужой строки. Если хотя бы один из них «случайно проходит», лучше остановиться и починить правила.
Представим CRM, где есть 3 организации: «Альфа», «Бета» и «Гамма». В таблицах contacts и deals у каждой записи есть org_id. Пользователи входят в систему, и приложение выставляет контекст сессии (например, текущую организацию и роль). Дальше RLS решает, какие строки можно видеть и менять.
Обычный пользователь (роль app_user) работает только со своими контактами и сделками. Если он попробует подменить org_id в INSERT или UPDATE, политика с WITH CHECK должна это запретить.
Админ организации (роль org_admin) управляет пользователями своей организации: создает, блокирует, меняет права. Но он все равно не должен видеть людей из других организаций. Частая ошибка - дать org_admin слишком широкие права на таблицу users, и тогда через join или поиск по email начинают «просачиваться» чужие записи.
Глобальная админка поддержки (роль support_admin) может видеть все организации, но через заранее предусмотренные операции. Практичный подход - ограничить доступ безопасными представлениями или строго нужными действиями, чтобы «по ошибке» нельзя было выгрузить всю базу.
Фоновая задача пересчета метрик (роль metrics_job) обычно ломается первой: она читает много строк сразу и упирается в RLS. Для нее лучше явный режим доступа: либо отдельная политика для чтения агрегатов, либо запуск с четко заданным контекстом org_id по очереди.
RLS лучше вводить не одним большим включением, а короткими шагами. Тогда вы увидите, где правила мешают реальным сценариям, и не уроните админку или фоновые задачи.
Удобный порядок:
Чаще всего проблемы оказываются не в формулах политики, а в контексте сессии: org_id не выставлен, выставлен не там, или выставлен не той ролью. Поэтому заранее договоритесь в команде, где и когда задается org_id, какие роли существуют, какие задачи выполняются сервисными ролями, и что считается допустимым обходом.
Если вы быстро собираете приложение на TakProsto (takprosto.ai), удобно держать схему, роли и сценарии в planning mode, а перед опасными изменениями пользоваться snapshots и rollback. Когда подход стабилизировался, зафиксируйте стандарт для новых таблиц: обязательный org_id, шаблон политик и минимальные тесты, и храните это рядом с миграциями и кодом (например, через экспорт исходников).
Когда вы включаете RLS, база начинает отдавать и менять строки только если это разрешено политикой. Если для таблицы нет подходящей политики или в сессии не установлен нужный контекст (например, app.org_id), запросы начинают возвращать пустые результаты или падать на записи.
Чаще всего org_id не выставлен в текущем соединении или выставлен не локально на транзакцию. В результате политика сравнивает org_id строки с NULL или с чужим значением и ничего не разрешает. Надежный вариант — устанавливать app.org_id через set_config(..., true) в начале каждой транзакции.
Проверка политики для INSERT и «новых значений» при UPDATE делается через WITH CHECK. Если его нет или условие не совпадает, вставка/обновление либо падают с ошибкой, либо запись оказывается «невидимой» сразу после изменения, потому что не проходит USING для чтения.
ENABLE ROW LEVEL SECURITY включает RLS для обычных запросов, но владелец таблицы и некоторые роли могут видеть больше, чем ожидается. FORCE ROW LEVEL SECURITY заставляет применять политики почти ко всем, включая владельца таблицы, и обычно лучше подходит для мультиорганизационных данных, чтобы не было «случайных» обходов.
Делайте org_id явным столбцом в каждой «организационной» таблице и стройте политики вокруг него. Если org_id спрятан в связанных таблицах, политики быстро превращаются в сложные подзапросы, а INSERT и UPDATE начинают неожиданно ломаться из‑за зависимостей от чужих таблиц.
Потому что это легко забыть в редких запросах, отчетах или админских ручках, и тогда утечка данных становится вопросом одной ошибки в коде. RLS делает защиту «по умолчанию закрыто»: даже если запрос написан без фильтра, база не отдаст чужие строки.
Ставьте app.org_id только локально на транзакцию, чтобы значение не «прилипало» к соединению в пуле. Если использовать обычный SET вне транзакции, одно физическое соединение может обслужить другого пользователя, и он получит неправильный org_id, что приводит к утечкам или к пустым результатам.
Сначала отличите RLS от обычных прав. permission denied for table почти всегда означает, что не хватает GRANT на таблицу или схему, а не проблему политики. А ошибка про нарушение row-level security обычно указывает на WITH CHECK или на отсутствие подходящей политики для операции.
Не отключайте RLS и не подключайтесь суперпользователем как «быстрое решение». Лучше добавьте отдельные политики для админской роли или используйте явный флаг в сессии, который выставляет только сервер приложения, и только локально на транзакцию. Так админские возможности будут контролируемыми и не превратятся в постоянный обход защиты.
Проверяйте RLS так же, как работает приложение: переключайте роль и устанавливайте app.org_id в транзакции, затем прогоняйте короткий набор CRUD-кейсов. Минимум — чтение «своих» строк, попытка прочитать чужой org_id, вставка и обновление с чужим org_id. Если что-то «случайно проходит», политика слишком широкая или контекст выставляется неправильно.