ADG Оставить заявку
Блог Данные и аналитика 4 мин чтения

PostgreSQL 9.5 RLS в мультиарендной схеме: политики вместо application-фильтрации

Реализуем мультиарендность для SaaS-клиента через Row-Level Security в PostgreSQL 9.5: политики на уровне строк, overhead на запросы, профилирование.

Контекст момента

PostgreSQL 9.5 Row-Level Security позволяет строить мультиарендные схемы изоляции данных на уровне СУБД

В январе, когда переводили ETL-пайплайны на UPSERT, бегло упомянули Row-Level Security. Один клиент сразу заинтересовался: у него SaaS-продукт с общей схемой, несколько десятков арендаторов в одной PostgreSQL БД, и изоляция данных сейчас реализована через условия в ORM. Типичное WHERE tenant_id = ? в каждом запросе, которое добавляется на уровне базового класса модели. Работает, но история знает, как это заканчивается: один пропущенный фильтр - и данные одного клиента видит другой. Мы решили проверить, может ли RLS закрыть этот вопрос на уровне базы.

Как устроена изоляция через RLS

Механика простая. Включаешь политику на таблице:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  AS PERMISSIVE
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant')::int);

Приложение при открытии сессии выставляет переменную:

SET app.current_tenant = 42;

После этого любой SELECT * FROM orders вернёт только строки с tenant_id = 42. Без WHERE, без условий в ORM. База фильтрует сама, политика применяется до выполнения запроса.

Важный нюанс: суперпользователь политики игнорирует. Это поведение по умолчанию (BYPASSRLS). Для приложения нужна отдельная роль без суперправ:

CREATE ROLE app_user LOGIN PASSWORD '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
-- Политика будет применяться автоматически для этой роли

Если нужно, чтобы и суперпользователь не обходил политику, есть ALTER TABLE orders FORCE ROW LEVEL SECURITY - но это уже для особо параноидальных случаев.

Что мы сделали и как настроили

Схема у клиента - один PostgreSQL 9.5, несколько таблиц с tenant_id. Вынесли SET app.current_tenant в middleware: каждый запрос к БД начинается с установки переменной в рамках той же транзакции. Использовали SET LOCAL, чтобы переменная жила только внутри транзакции и не протекала между запросами при использовании connection pool:

BEGIN;
SET LOCAL app.current_tenant = 42;
SELECT * FROM orders WHERE status = 'active';
COMMIT;

Политики создали на пяти основных таблицах. Для INSERT добавили отдельную политику с WITH CHECK, чтобы нельзя было вставить строку с чужим tenant_id:

CREATE POLICY tenant_insert ON orders
  AS PERMISSIVE
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant')::int);

Профилирование: что с overhead

Это был главный вопрос. Теоретически политика - это дополнительное условие в плане запроса. На практике хотелось понять, насколько это ощутимо.

Взяли таблицу с несколькими сотнями тысяч строк, примерно равномерно распределёнными по арендаторам. Сравнили два варианта запроса - с явным WHERE tenant_id = ? в приложении (старая схема) и через RLS (новая).

EXPLAIN (ANALYZE, BUFFERS) показал идентичные планы: в обоих случаях использовался индекс по (tenant_id, created_at), условие на tenant_id попадало в Index Cond. PostgreSQL добавляет условие из политики до планирования запроса - планировщик видит его наравне с остальными предикатами и строит план так же, как если бы WHERE был написан явно.

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

Единственное, что добавляет накладные расходы - это current_setting('app.current_tenant') в каждом вызове. Это обращение к сессионным переменным PostgreSQL. По данным pg_stat_statements разница в execution time одиночного запроса - несколько микросекунд, на фоне реального I/O это незаметно.

Где стало удобнее, а где нет

Плюс, который почувствовали сразу. Прямой SQL-доступ для аналитиков клиента. Раньше нельзя было дать им подключение к БД - они бы видели данные всех арендаторов. С RLS можно выдать роль с нужным tenant_id, и аналитик работает только со своими данными даже в psql или DBeaver.

Плюс для DWH-проектов. Разграничение доступа в аналитическом хранилище без создания view-обёрток на каждую таблицу. Политика пишется один раз и работает везде.

Что оказалось неудобным. ORM. SQLAlchemy при открытии соединения из пула не вызывает SET LOCAL автоматически - нужно подключаться к событию checkout и выставлять переменную там. Это несложно, но требует явной настройки. Забыть легко.

Также обнаружили, что EXPLAIN без установленной переменной app.current_tenant падает с ошибкой unrecognized configuration parameter. Мелочь, но разработчиков это поначалу сбивает с толку при отладке.

Текущий статус

Перевели три таблицы в опытную эксплуатацию. Приложение работает с RLS уже несколько недель, жалоб нет, overhead в мониторинге не виден. ORM-интеграцию задокументировали, чтобы не изобретать каждый раз.

Пока не трогали схемы, где tenant-изоляция сложнее - например, когда данные арендатора живут в нескольких таблицах со сложными JOIN-ами и права зависят от роли пользователя внутри арендатора. Там одной политикой не обойтись, нужно продумывать набор ролей. Но для базового случая "один арендатор - один tenant_id" RLS закрывает задачу аккуратно и без лишнего кода в приложении.

Контакт

Нужна такая же инженерная работа?

Опишите задачу и контекст. Ответим в течение рабочего дня, при необходимости подпишем NDA.