PostgreSQL RLS для SaaS помогает обеспечить изоляцию тенантов в базе данных. Узнайте, когда его использовать, как писать политики и чего избегать.

В SaaS-приложении самая опасная ошибка безопасности — та, которая проявляется после масштабирования. Вы начинаете с простого правила вроде «пользователи могут видеть только данные своего тенанта», затем быстро добавляете новый эндпоинт, добавляете отчётный запрос или вводите join, который незаметно пропускает проверку.
Авторизация только в приложении ломается под нагрузкой, потому что правила оказываются разбросаны по коду. Один контроллер проверяет tenant_id, другой проверяет членство, фоновая задача забывает, а путь «admin export» остаётся «временным» месяцами. Даже внимательные команды упускают место.
PostgreSQL row-level security (RLS) решает конкретную проблему: он заставляет базу данных контролировать, какие строки видимы для данного запроса. Ментальная модель простая: каждый SELECT, UPDATE и DELETE автоматически фильтруется политиками, как каждый запрос фильтруется middleware аутентификации.
Важно, что речь идёт о «строках». RLS не защитит всё:
Конкретный пример: вы добавляете эндпоинт, который перечисляет проекты с join до invoice'ов для дашборда. При авторизации только в приложении легко отфильтровать projects по тенанту, но забыть отфильтровать invoices, либо сделать join по ключу, который пересекает тенанты. С RLS обе таблицы могут обеспечивать изоляцию тенантов, так что запрос безопасно не вернёт данных, вместо того чтобы их раскрыть.
Компромисс реальный. Вы пишете меньше повторяющегося кода авторизации и уменьшаете число мест, где возможна утечка. Но вы берёте на себя новую работу: политики нужно проектировать внимательно, тестировать рано и принимать то, что политика может заблокировать запрос, который вы ожидали работать.
RLS может казаться лишней работой, пока ваше приложение не вырастет больше пары эндпоинтов. Если у вас строгие границы тенантов и много путей запросов (страницы списков, поиск, экспорты, админ-инструменты), запись правила в базе значит, что вам не нужно помнить о добавлении одного и того же фильтра везде.
RLS хорошо подходит, когда правило скучное и универсальное: «пользователь может видеть только строки своего тенанта» или «пользователь может видеть только проекты, в которых он состоит». В таких сценариях политики уменьшают ошибки, потому что каждый SELECT, UPDATE и DELETE проходит через один и тот же шлюз, даже если запрос появится позже.
Он также полезен для приложений с большим объёмом чтений, где логика фильтрации остаётся последовательной. Если ваш API имеет 15 разных способов загрузки инвойсов (по статусу, по дате, по клиенту, по поиску), RLS позволяет перестать переавторизовывать фильтрацию по тенанту в каждом запросе и сосредоточиться на функционале.
RLS добавляет боли, когда правила не основаны на строках. Правила по полям вроде «видно зарплату, но не бонус» или «маскировать колонку, если вы не HR» часто превращаются в неудобный SQL и плохо поддерживаемые исключения.
Это также тяжёлый вариант для обширной отчётности, которой действительно нужен широкий доступ. Команды часто создают обходные роли «только для этой задачи», и именно там накапливаются ошибки.
Прежде чем принимать решение, решите, хотите ли вы, чтобы база была финальным контролёром доступа. Если да — планируйте дисциплину: тестируйте поведение базы (не только ответы API), относитесь к миграциям как к изменениям безопасности, избегайте быстрых обходов, решите, как фоновые задания аутентифицируются, и держите политики маленькими и повторяемыми.
Если вы используете инструменты, генерирующие бэкенд, они могут ускорить доставку, но не снимают необходимости в ясных ролях, тестах и простой модели тенанта. (Например, Koder.ai использует Go и PostgreSQL для сгенерированных бэкендов, и вы всё равно захотите проектировать RLS осознанно, а не «подмешивать его потом».)
RLS проще всего, когда ваша схема уже чётко говорит, кому что принадлежит. Если вы начинаете с размытой модели и пытаетесь «исправить это политиками», обычно получаете медленные запросы и запутанные баги.
Выберите один ключ тенанта (например, org_id) и используйте его последовательно. Большинство таблиц, принадлежащих тенанту, должны иметь его, даже если они ссылаются на другую таблицу, которая тоже его содержит. Это избегает джоинов внутри политик и держит проверки USING простыми.
Практическое правило: если строка должна исчезать, когда клиент отменяет подписку, ей, скорее всего, нужен org_id.
Политики RLS обычно отвечают на вопрос: «Является ли этот пользователь членом этой организации, и что он может делать?» Это тяжело вывести из случайных колонок.
Держите основные таблицы маленькими и простыми:
users (по одной записи на человека)orgs (по одной записи на тенант)org_memberships (user_id, org_id, role, status)project_memberships для доступа на уровне проектаС этим набором политики могут проверять членство одним индексированным запросом.
Не всё должно иметь org_id. Справочные таблицы вроде стран, категорий продуктов или типов планов часто общие для всех тенантов. Сделайте их доступными только для чтения для большинства ролей и не привязывайте их к одному org.
Данные, принадлежащие тенанту (проекты, инвойсы, тикеты), должны избегать подтягивания тенант-специфичных деталей через общие таблицы. Делайте справочные таблицы минимальными и стабильными.
Внешние ключи работают с RLS, но удаления могут удивлять, если роль, выполняющая удаление, не «видит» зависимые строки. Планируйте каскады внимательно и тестируйте реальные сценарии удаления.
Индексируйте колонки, по которым фильтруют политики, особенно org_id и ключи членств. Политика, выглядящая как WHERE org_id = ..., не должна превращаться в полное сканирование таблицы, когда число строк достигает миллионов.
RLS — это переключатель на уровне таблицы. После включения PostgreSQL перестаёт доверять коду приложения в вопросе фильтрации тенанта. Каждый SELECT, UPDATE и DELETE фильтруется политиками, а каждый INSERT и UPDATE проверяется ими.
Крупный ментальный сдвиг: с включённым RLS запросы, которые раньше возвращали данные, могут начать возвращать ноль строк без ошибок. Это PostgreSQL, выполняющий контроль доступа.
Политики — это небольшие правила, прикреплённые к таблице. Они используют два типа проверок:
USING — фильтр для чтения. Если строка не соответствует USING, она невидима для SELECT, и её нельзя целенаправленно обновить или удалить.WITH CHECK — ворота для записи. Решает, какие новые или изменённые строки разрешены при INSERT или UPDATE.Обычный SaaS-паттерн: USING гарантирует, что вы видите только строки вашего тенанта, а WITH CHECK гарантирует, что вы не сможете вставить строку в чужой тенант, подделав tenant_id.
Когда вы добавляете больше политик, это важно:
PERMISSIVE (по умолчанию): строка разрешена, если любая политика позволяет её.RESTRICTIVE: строка разрешена только если все restrictive-политики позволяют её (в дополнение к permissive-поведению).Если вы планируете накладывать слой правил вроде соответствия тенанта + проверок ролей + членства в проекте, restrictive-политики могут прояснить намерение, но они также повышают риск «заблокировать себя», если вы забудете одно условие.
RLS нуждается в надёжном значении «кто делает вызов». Распространённые варианты:
app.user_id и app.tenant_id).SET ROLE ...) на запросе, что работает, но добавляет операционных сложностей.Выберите один подход и применяйте его везде. Смешение источников идентичности между сервисами — быстрый путь к запутанным багам.
Используйте предсказуемую конвенцию, чтобы дампы схем и логи были читабельны. Например: {table}__{action}__{rule}, вроде projects__select__tenant_match.
Если вы новичок в RLS, начните с одной таблицы и небольшого proof. Цель не в идеальном покрытии. Цель — заставить базу отказываться от кросс-тенантного доступа, даже когда в приложении есть баг.
Предположим простую таблицу projects. Сначала добавьте tenant_id так, чтобы это не сломало запись.
ALTER TABLE projects ADD COLUMN tenant_id uuid;
-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;
ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;
Далее, отделите собственника от роли API. Частый паттерн: одна роль владеет таблицами (app_owner), другая роль используется API (app_user). Роль API не должна быть владельцем таблиц, иначе она может обходить политики.
ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;
Теперь решите, как запрос сообщает Postgres, какой тенант он обслуживает. Один простой подход — установка сессионной настройки на протяжении транзакции. Ваше приложение устанавливает её сразу после открытия транзакции.
-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);
Включите RLS и начните с правил чтения.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Доказать это просто: попробуйте два разных тенанта и проверьте, что количество строк меняется.
Политики чтения не защищают записи. Добавьте WITH CHECK, чтобы вставки и обновления не могли подбросить строки в чужой тенант.
CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Быстрый способ проверить поведение (включая ошибки) — держать небольшой SQL-скрипт, который можно запускать после каждой миграции:
BEGIN; SET LOCAL ROLE app_user;SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad'); (должно упасть)UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...; (должно упасть)ROLLBACK;Если вы можете запускать этот скрипт и получать одинаковые результаты каждый раз, у вас есть надёжная базовая линия перед распространением RLS на другие таблицы.
Большинство команд обращаются к RLS, устав повторять одни и те же проверки авторизации в каждом запросе. Хорошая новость: формы политик обычно последовательны.
Некоторые таблицы естественно принадлежат одному пользователю (заметки, API-токены). Другие — принадлежат организации, где доступ зависит от членства. Обращайтесь с ними как с разными шаблонами.
Для данных, которыми владеет один пользователь, политики часто проверяют created_by = app_user_id(). Для данных организации политики часто проверяют наличие строки членства для этой организации.
Практический способ держать политики читаемыми — централизовать идентичность в небольших SQL-хелперах и переиспользовать их:
-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
select current_setting('app.user_id', true)::uuid
$$;
create function app_is_admin() returns boolean
language sql stable as $$
select current_setting('app.is_admin', true) = 'true'
$$;
Чтение часто шире, чем запись. Например, любой член организации может SELECT проекты, но только редакторы могут UPDATE, а только владельцы — DELETE.
Держите это явно: одна политика для SELECT (членство), одна политика для INSERT/UPDATE с WITH CHECK (роль), и одна для DELETE (часто строже, чем update).
Избегайте «выключить RLS для админов». Вместо этого добавляйте спасательный путь внутри политик, вроде app_is_admin(), чтобы случайно не дать полный доступ общей сервисной роли.
Если вы используете deleted_at или status, включите это в политику SELECT (deleted_at is null). Иначе кто-то сможет «оживить» строки, меняя флаги, которые приложение считало финальными.
WITH CHECK дружелюбнымINSERT ... ON CONFLICT DO UPDATE должен удовлетворять WITH CHECK для строки после записи. Если политика требует created_by = app_user_id(), убедитесь, что upsert задаёт created_by при вставке и не перезаписывает его при обновлении.
Если вы генерируете бэкенд-код, эти паттерны стоит превратить во внутренние шаблоны, чтобы новые таблицы сразу начинались с безопасных настроек, а не с пустого листа.
RLS хорош, пока одна мелочь не заставит его выглядеть так, будто PostgreSQL «случайно» прячет или показывает данные. Ошибки ниже тратят больше всего времени.
Первая ловушка — забыть WITH CHECK на вставке и обновлении. USING контролирует видимость, а не то, что разрешено создавать. Без WITH CHECK баг в приложении может записать строку в чужой тенант, и вы можете не заметить этого, потому что тот же пользователь не сможет прочитать её.
Другая распространённая утечка — «leaky join». Вы правильно фильтруете projects, затем делаете join к invoices, notes или files, которые не защищены так же. Решение строгое, но прямое: каждая таблица, которая может раскрыть данные тенанта, нуждается в собственной политике, и представления не должны опираться только на одну «безопасную» таблицу.
Распространённые ошибки проявляются рано:
WITH CHECK.Политики, которые ссылаются на ту же таблицу (напрямую или через view), могут создать рекурсивные сюрпризы. Политика может проверять членство, запрашивая view, который читает защищаемую таблицу снова, что ведёт к ошибкам, медленным запросам или политике, которая никогда не матчится.
Настройка ролей — ещё один источник путаницы. Владельцы таблиц и роли с повышенными правами могут обходить RLS, поэтому ваши тесты проходят, а реальные пользователи терпят фейлы (или наоборот). Всегда тестируйте с той же низкоприоритетной ролью, которую использует ваше приложение.
Будьте осторожны с SECURITY DEFINER функциями. Они выполняются с привилегиями владельца функции, поэтому хелпер вроде current_tenant_id() может быть в порядке, но «удобная» функция, читающая данные, может случайно прочитать через тенанты, если её не спроектировать так, чтобы уважать RLS.
Также задайте безопасный search_path внутри security definer функций. Иначе функция может подобрать другой объект с тем же именем, и логика политики может молча указывать не туда в зависимости от сессионного состояния.
Баги RLS обычно связаны с отсутствием контекста, а не «плохим SQL». Политика может быть корректной на бумаге и всё равно не работать, потому что сессия использует другую роль, или потому что запрос никогда не установил значения тенанта и пользователя, от которых зависит политика.
Надёжный способ воспроизвести продакшен-репорт — смоделировать ту же сессию локально и выполнить точный запрос. Обычно это значит:
SET ROLE app_user; (или реальная роль API)SELECT set_config('app.tenant_id', 't_123', true); и SELECT set_config('app.user_id', 'u_456', true);SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);Если вы не уверены, какая политика применяется, смотрите в каталог, а не догадывайтесь. pg_policies показывает каждую политику, команду и выражения USING и WITH CHECK. Сопоставьте это с pg_class, чтобы убедиться, что RLS включён на таблице и не обходится.
Проблемы с производительностью могут выглядеть как проблемы авторизации. Политика, которая делает join к таблице членств или вызывает функцию, может быть корректной, но медленной, когда таблица растёт. Используйте EXPLAIN (ANALYZE, BUFFERS) на воспроизведённом запросе и ищите последовательные сканирования, неожиданные вложенные циклы или фильтры, применённые поздно. Отсутствие индексов на (tenant_id, user_id) и таблицах членств — частая причина.
Полезно логировать три значения для каждого запроса на уровне приложения: tenant ID, user ID и роль базы, используемую для запроса. Когда они не совпадают с тем, что вы думаете, RLS будет вести себя «неправильно», потому что входные данные неверны.
Для тестов держите несколько тестовых тенантов и делайте ошибки явными. Небольшой набор тестов обычно включает: «Tenant A не может читать Tenant B», «пользователь без членства не видит проект», «владелец может обновлять, зритель — нет», «вставка блокируется, если tenant_id не совпадает с контекстом», и «админ-овверрайд применяется только там, где задумано».
Относитесь к RLS как к ремню безопасности, а не к переключателю фичи. Маленькие промахи превращаются в «все видят данные всех» или «всё возвращает ноль строк».
Убедитесь, что дизайн таблиц и правила политики соответствуют вашей модели тенанта.
tenant_id). Если его нет — зафиксируйте причину (например, глобальные справочные таблицы).FORCE ROW LEVEL SECURITY для таких таблиц.USING. Запись обязана иметь WITH CHECK, чтобы вставки и обновления не могли переместить строку в другой тенант.tenant_id или делают join через таблицы членств, добавьте соответствующие индексы.Простая проверка здравомыслия: пользователь из tenant A может читать свои инвойсы, может вставить инвойс только для tenant A и не может изменить tenant_id инвойса.
RLS сильна ровно настолько, насколько корректны роли, которыми пользуется приложение.
bypassrls.Представьте B2B-приложение, где компании (orgs) имеют проекты, а проекты — задачи. Пользователи могут принадлежать нескольким организациям, а пользователь может быть участником некоторых проектов, но не всех. Это хороший кейс для RLS, потому что база может обеспечить изоляцию тенантов, даже если API-забывает фильтр.
Простая модель: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...). Колонка org_id в tasks намеренна. Она упрощает политики и уменьшает сюрпризы при джоинах.
Классическая утечка происходит, когда у задач есть только project_id, а политика проверяет доступ через join к projects. Одна ошибка (слишком разрешающая политика на projects, join, который теряет условие, или view, меняющее контекст) может открыть задачи другой организации.
Более безопасный путь миграции, чтобы не сломать прод:
org_id в tasks, добавьте таблицы членств).tasks.org_id из projects.org_id, затем сделайте NOT NULL.Доступ поддержки обычно лучше обрабатывать узкой ролью «break-glass», а не отключением RLS. Держите её отдельно от обычных аккаунтов поддержки и фиксируйте её использование.
Документируйте правила, чтобы политики не расходились: какие сессионные переменные должны быть установлены (user_id, org_id), какие таблицы должны нести org_id, что значит «член», и несколько SQL-примеров, которые должны возвращать 0 строк при запуске от имени чужой организации.
RLS проще жить, если относиться к нему как к изменению продукта. Внедряйте по частям, доказывайте поведение тестами и держите чёткую запись причин каждой политики.
План отката, который обычно работает:
projects) и зафиксируйте её.После стабилизации первой таблицы делайте изменения политик осознанно. Добавьте шаг ревью политик в миграции и прикладывайте краткую заметку о назначении (кто должен иметь доступ к чему и почему) плюс соответствующее обновление тестов. Это предотвращает «просто добавь ещё OR» политики, которые постепенно превращаются в дыру.
Если вы двигаетесь быстро, инструменты вроде Koder.ai (koder.ai) могут помочь сгенерировать Go + PostgreSQL стартовую точку через чат, а затем вы сможете наложить политики RLS и тесты с той же дисциплиной, что и в ручном бэкенде.
Наконец, держите страховочные меры при rollout: снимайте снапшоты перед миграциями политик, отрабатывайте откаты, пока это не станет скучным, и держите узкий break-glass путь для поддержки, который не отключает RLS для всей системы.
RLS заставляет PostgreSQL контролировать, какие строки видимы или доступны для записи в рамках запроса, поэтому изоляция тенантов не зависит от того, что каждый эндпоинт помнит фильтр WHERE tenant_id = .... Главное преимущество — уменьшение числа багов «одна пропущенная проверка», которые появляются по мере роста приложения и множества запросов.
Это оправдано, когда правила доступа единообразны и применимы к строкам — например, изоляция по тенанту или доступ на основе членства — и у вас много путей выборки данных (поиск, экспорт, админки, фоновые задания). Обычно это нецелесообразно, если правила в основном поколоночные, сильно исключаемые или доминируют отчёты, которым нужны кросс-тенантные чтения.
RLS защищает видимость строк и базовую проверку записей. Конфиденциальность колонок обычно требует представлений и прав на колонки, а сложные бизнес-правила (например, владение биллингом или согласовательные процессы) всё ещё нужно реализовывать в логике приложения или через грамотно спроектированные ограничения БД.
Создайте роль с низкими привилегиями для API (не владелец таблиц), включите RLS, затем добавьте политику SELECT и политику INSERT/UPDATE с WITH CHECK. Установите сессионную переменную для запроса, например app.current_tenant, и проверьте, что при её переключении меняются видимые и записываемые строки.
Обычный вариант — сессионные переменные, задаваемые на протяжении запроса (например, app.tenant_id и app.user_id). Главное — последовательность: все пути (веб-запросы, фоновые задачи, скрипты) должны устанавливать те же значения, которые ожидают политики, иначе вы получите запутанное поведение «нулевых строк».
USING контролирует, какие существующие строки видимы и могут быть целью для SELECT, UPDATE и DELETE. WITH CHECK контролирует, какие новые или изменённые строки допустимы при INSERT и , поэтому он предотвращает «запись в чужой тенант», даже если приложение передало неверный .
Если добавить только USING, баг в эндпоинте всё ещё может вставить или обновить строку в чужой тенант, и вы можете этого не заметить, потому что тот же пользователь не сможет прочитать ошибочную строку. Всегда сопоставляйте правило чтения с соответствующим WITH CHECK для записи, чтобы неподходящие данные не могли появиться изначально.
Избегайте джоинов внутри условий политик: положите ключ тенанта (например, org_id) прямо в таблицы, принадлежащие тенанту, даже если у них есть ссылка на другую таблицу с тем же ключом. Добавьте явные таблицы членств (org_memberships, опционально project_memberships), чтобы политики делали один индексированный запрос вместо сложного вывода.
Сначала воспроизведите тот же сессионный контекст, который использует приложение: установите ту же роль и сессионные настройки, затем выполните точный SQL-запрос. Проверьте, включён ли RLS и загляните в pg_policies, чтобы увидеть USING и WITH CHECK выражения — чаще всего проблема в отсутствии правильно установленных идентификационных значений, а не в «плохом SQL».
Да, но относитесь к сгенерированному коду как к отправной точке, а не к системе безопасности. Если вы используете Koder.ai для генерации Go + PostgreSQL бэкенда, вам всё равно нужно определить модель тенанта, последовательно задавать сессионную идентичность и явно добавлять политики и тесты, чтобы новые таблицы не выпускались без нужной защиты.
UPDATEtenant_id