Row Level Security: опускаем изоляцию арендаторов на уровень базы
Когда вы пишете мультиарендную (multi-tenant) систему, самая страшная ошибка — не падение. Падение видно, оно лежит в логах, вы его чините. Настоящий страх в другом: в одном запросе вы забыли написать WHERE organization_id = ?, ничего не сломалось, система выглядит работающей — и один клиент видит данные другого.
Пока я писал систему управления школой, я долго жил с этим риском. В каждой таблице есть идентификатор организации, в каждом запросе он должен присутствовать. Достаточно одного забытого места. А данные серьёзные: списки учеников, оценки, посещаемость, контакты родителей.
Эта статья о том, как опустить эту проблему со слоя приложения на слой базы данных.
Проблема: дисциплина — не защита
Обычное решение выглядит так: в слое репозитория каждый метод принимает organizationId, и передавать его требуется везде.
Это работает. Точнее, работает, пока все пишут без ошибок.
Беда в том, что такой подход опирается на дисциплину, а не на механизм. В команду приходит новый разработчик, нужен срочный фикс, кто-то пишет быстрый SELECT — и защита продырявлена. Хуже того: дырка открывается молча. Ни ошибки, ни алерта.
Правило простое: если безопасность зависит от того, что человек каждый раз напишет правильно, это не безопасность, а надежда.
Что делает RLS
Механизм Row Level Security в PostgreSQL снимает условие с запроса и привязывает его к самой таблице. После включения база возвращает только те строки, которые разрешает политика, — независимо от того, кто и какой запрос написал.
Самая простая форма:
alter table students enable row level security;
create policy students_tenant_isolation on students
using (organization_id = current_setting('app.organization_id')::uuid);Теперь даже select * from students вернёт только строки текущей организации. Забытый WHERE больше не приводит к утечке — он просто даёт пустой результат.
Контекст устанавливается на каждое соединение:
set local app.organization_id = '90e60e0c-4cb7-433f-b968-5f718ed3133a';set local — важная деталь. Он держит значение только внутри текущей транзакции. Если использовать обычный set, значение останется на соединении, а соединения переиспользуются через пул — и следующий запрос в контексте другого клиента увидит не ту организацию. Это самая тихая и самая опасная ошибка, связанная с RLS.
Запись тоже нужно ограничить
Условие using ограничивает только чтение. Чтобы закрыть запись, нужен with check:
create policy students_insert on students
for insert
with check (organization_id = current_setting('app.organization_id')::uuid);Без него пользователь может вставить строку с чужим идентификатором организации — сам он её потом не увидит, но данные появятся в таблице другого клиента. Это ошибка того типа, что долго остаётся незамеченной, особенно в отчётах.
Две серьёзные ловушки
Первая — владелец таблицы не подчиняется RLS. Если и миграции, и запросы приложения выполняются одной ролью, то RLS включён, политики написаны, а на деле ничего не ограничено. Приложение должно подключаться отдельной ролью, не являющейся владельцем.
Вторая — force row level security. В документации написано, что он «принуждает и владельца», и на первый взгляд это выглядит правильным шагом. Но если у вас есть функции security definer, они выполняются от имени владельца функции, и force заблокирует их тоже. Я столкнулся с этим, когда писал систему комментариев для блога: локально всё работало, а после добавления force все функции записи упали с ошибкой доступа.
Правило: включайте force только если у вас нет функций security definer.
Вариант без политик: ноль policy
Политики нужны не всегда. В базе, которая хранит счётчики просмотров и комментарии для блога, я пошёл другим путём: RLS включён, но не написано ни одной политики.
Результат интересный — если политик нет, RLS не даёт никому ничего. То есть человек, подключившийся к базе напрямую с публичным ключом, получает нулевые права. А все запросы идут через серверный route handler под служебной ролью.
Это тоже полноценная изоляция, просто в другой модели: доступ к данным проходит через единственную дверь, а RLS закрывает боковые входы.
Выбор такой: если клиенты подключаются к базе напрямую — пишите политики. Если все запросы идут через ваш сервер — оставить RLS в состоянии «всё закрыто» проще и надёжнее.
Как тестировать политики
После написания RLS его обязательно нужно проверить, потому что ошибка работает молча. Самая полезная форма теста, которую я нашёл, — две организации, один запрос:
begin;
set local app.organization_id = 'aaaa...';
select count(*) from students; -- только ученики A
rollback;
begin;
set local app.organization_id = 'bbbb...';
select count(*) from students; -- только ученики B
rollback;Если оба числа верны и в сумме сходятся с общим количеством — изоляция работает.
Второй важный тест — запрос без контекста. Отправьте запрос, вообще не устанавливая app.organization_id. В правильно настроенной системе он вернёт ошибку или пустой результат. Если вернулась вся таблица — значит, политика фактически не применяется, и вы, скорее всего, работаете под ролью владельца таблицы.
Есть ли цена
Есть, но не такая, как многие думают. Условие RLS добавляется в запрос, и планировщик видит его как обычный WHERE. То есть если по organization_id есть индекс — он будет использован.
Практический совет: в мультиарендных таблицах при составном индексе ставьте колонку организации первой. (organization_id, created_at) почти всегда полезнее, чем (created_at, organization_id), потому что каждый запрос всё равно фильтруется по организации.
Вывод
RLS — не магия, и он не отменяет проверок в слое приложения. Он даёт одну конкретную вещь: делает изоляцию арендаторов невозможной для забывания.
Порядок внедрения такой. Сначала подключите приложение ролью без прав владельца. Затем включите RLS на одной самой чувствительной таблице и напишите политику с using и with check. Передавайте контекст через set local и обязательно внутри транзакции. Напишите тест с двумя организациями и проверьте вариант без контекста. Убедившись, что всё работает, распространяйте на остальные таблицы.
И прежде чем трогать force row level security, проверьте, нет ли у вас функций security definer. Одна эта строка способна остановить весь поток записи.