Мультиарендность (multi-tenancy) — один из базовых паттернов SaaS-архитектуры. Несколько клиентов (арендаторов) используют одну систему, и ключевая задача — гарантировать, что данные одного клиента никогда не попадут к другому. PostgreSQL Row Level Security (RLS) позволяет решить эту задачу на уровне самой базы данных.
Три модели мультиарендности
Прежде чем говорить о RLS, важно понять, какая модель хранения данных используется в системе. Существует три устоявшихся подхода.
Silo — изолированные базы
Каждый арендатор получает собственную базу данных или даже отдельный сервер PostgreSQL. Данные полностью изолированы на уровне инфраструктуры.
Bridge — схема на арендатора
Все арендаторы находятся в одной базе данных, но каждый получает отдельную схему (schema). Таблица orders арендатора A находится в схеме tenant_a.orders, арендатора B — в tenant_b.orders.
Pool — общие таблицы
Все арендаторы разделяют одни и те же таблицы. Каждая строка содержит столбец tenant_id, указывающий на её владельца. Изоляция обеспечивается на уровне запросов или политик базы данных.
| Критерий | Silo | Bridge | Pool |
|---|---|---|---|
| Изоляция данных | Максимальная (уровень ОС/СУБД) | Высокая (уровень схемы) | Средняя (уровень строк) |
| Стоимость инфраструктуры | Высокая (N серверов) | Средняя (1 сервер, N схем) | Низкая (1 сервер, 1 набор таблиц) |
| Операционная сложность | Высокая (миграции N баз) | Средняя | Низкая |
| Масштабируемость | Ограничена количеством серверов | Ограничена (сотни схем) | Высокая (десятки тысяч арендаторов) |
| Соответствие требованиям (GDPR и др.) | Упрощает compliance | Умеренно | Требует дополнительной защиты |
| Подходящий инструмент изоляции | Не требуется | Схемы PostgreSQL | RLS |
Что такое Row Level Security
Row Level Security — механизм PostgreSQL, позволяющий автоматически добавлять условия фильтрации к любому запросу на уровне ядра СУБД. Грубо говоря, это автоматический WHERE, который PostgreSQL добавляет к каждому SELECT, INSERT, UPDATE и DELETE перед выполнением.
Принципиальное отличие от фильтрации в приложении: RLS применяется всегда, независимо от того, какой код выполняет запрос. Даже если разработчик случайно забыл добавить WHERE tenant_id = ? в запрос, база данных добавит это условие сама.
Без RLS изоляция арендаторов полностью зависит от корректности кода приложения. Один пропущенный WHERE в любом из сотен запросов — и данные одного клиента уйдут другому. RLS переносит ответственность за изоляцию на уровень, который нельзя обойти случайно.
Как работают политики RLS
Политика RLS — это именованное правило, привязанное к таблице. Она состоит из двух частей:
- USING: условие для SELECT, UPDATE, DELETE — определяет, какие строки видны пользователю
- WITH CHECK: условие для INSERT и UPDATE — определяет, какие строки пользователь может создавать или изменять
Если для таблицы задано несколько политик, PostgreSQL объединяет их через OR (если политики одного типа) или применяет последовательно. Политики можно назначать на конкретные операции: FOR SELECT, FOR INSERT, FOR UPDATE, FOR DELETE, или FOR ALL.
По умолчанию RLS работает только для пользователей, не являющихся владельцами таблицы и не имеющих привилегии BYPASSRLS. Включить RLS на таблице нужно явно командой ALTER TABLE ... ENABLE ROW LEVEL SECURITY.
Практические примеры SQL
Создание структуры данных
Начнём с создания таблиц арендаторов, пользователей и бизнес-данных. В модели Pool все данные живут в одних таблицах, разделённых по tenant_id.
-- Таблица арендаторов (компаний)
CREATE TABLE tenant (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
-- Пользователи PostgreSQL для каждого арендатора
CREATE USER tenant_1_user WITH PASSWORD 'secret1';
CREATE USER tenant_2_user WITH PASSWORD 'secret2';
-- Пользователи системы (привязаны к арендатору)
CREATE TABLE tenant_user (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenant(id),
pg_user TEXT NOT NULL, -- имя пользователя PostgreSQL
email TEXT NOT NULL
);
-- Бизнес-данные: заказы
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenant(id),
amount NUMERIC(10, 2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
Включение RLS и создание политики изоляции
-- Включаем RLS на таблице заказов
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Политика: пользователь видит только строки своего арендатора.
-- current_user — встроенная функция PostgreSQL, возвращает имя
-- текущего пользователя базы данных.
CREATE POLICY tenant_isolation ON orders
USING (
tenant_id = (
SELECT tenant_id FROM tenant_user
WHERE pg_user = current_user
)
);
-- Выдаём права арендаторам на таблицу
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO tenant_1_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO tenant_2_user;
GRANT SELECT ON tenant_user TO tenant_1_user;
GRANT SELECT ON tenant_user TO tenant_2_user;
Наполнение тестовыми данными
-- Арендаторы
INSERT INTO tenant (name) VALUES ('Компания А'), ('Компания Б');
-- Пользователи системы
INSERT INTO tenant_user (tenant_id, pg_user, email)
VALUES
(1, 'tenant_1_user', 'admin@company-a.ru'),
(2, 'tenant_2_user', 'admin@company-b.ru');
-- Заказы (от имени владельца, который обходит RLS)
INSERT INTO orders (tenant_id, amount, status) VALUES
(1, 1500.00, 'completed'),
(1, 2300.00, 'pending'),
(2, 750.00, 'completed'),
(2, 4100.00, 'processing');
Демонстрация изоляции: SELECT
-- Владелец таблицы видит все строки (обходит RLS)
SELECT id, tenant_id, amount FROM orders;
-- id | tenant_id | amount
-- ----+-----------+---------
-- 1 | 1 | 1500.00
-- 2 | 1 | 2300.00
-- 3 | 2 | 750.00
-- 4 | 2 | 4100.00
-- Арендатор 1 видит только свои строки
SET ROLE tenant_1_user;
SELECT id, tenant_id, amount FROM orders;
-- id | tenant_id | amount
-- ----+-----------+---------
-- 1 | 1 | 1500.00
-- 2 | 1 | 2300.00
-- Арендатор 2 не может видеть данные арендатора 1
-- (и даже не знает, что такие строки существуют)
SET ROLE tenant_2_user;
SELECT id, tenant_id, amount FROM orders WHERE id = 1;
-- (0 rows)
Защита от UPDATE и DELETE чужих данных
-- Арендатор 2 пытается изменить заказ арендатора 1
SET ROLE tenant_2_user;
UPDATE orders SET amount = 0.01 WHERE id = 1;
-- UPDATE 0 (строка не видна → обновление не произошло)
DELETE FROM orders WHERE id = 1;
-- DELETE 0 (строка не видна → удаление не произошло)
-- Никаких ошибок — строка просто "не существует" для этого пользователя.
-- Это важно: атакующий не получает информации о том, что строка вообще есть.
Защита от INSERT с чужим tenant_id
Политика USING защищает чтение и изменение. Для защиты вставки нужна отдельная политика с WITH CHECK.
-- Добавляем политику INSERT
CREATE POLICY tenant_insert_isolation ON orders
FOR INSERT
WITH CHECK (
tenant_id = (
SELECT tenant_id FROM tenant_user
WHERE pg_user = current_user
)
);
-- Арендатор 1 пытается создать заказ от имени арендатора 2
SET ROLE tenant_1_user;
INSERT INTO orders (tenant_id, amount, status)
VALUES (2, 9999.00, 'completed');
-- ERROR: new row violates row-level security policy for table "orders"
WITH CHECK PostgreSQL возвращает явную ошибку — в отличие от SELECT/UPDATE/DELETE, где строки просто становятся невидимыми. Это корректное поведение: при чтении раскрывать факт существования строки нежелательно, при записи явная ошибка помогает отлаживать код.
Обход политик: суперпользователи и владельцы
RLS не является абсолютной защитой от всех пользователей PostgreSQL. Важно понимать, кто может обойти политики.
Кто обходит RLS
- Суперпользователи (
SUPERUSER) — всегда обходят RLS, это фундаментальное свойство PostgreSQL - Пользователи с атрибутом
BYPASSRLS— явно выданное право пропускать политики - Владелец таблицы — по умолчанию тоже обходит RLS
Принудительное применение RLS для владельца
Если по архитектурным причинам приложение подключается от имени владельца таблицы, можно принудительно включить RLS даже для него:
-- Владелец таблицы тоже обязан соблюдать политики
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- Проверка: теперь даже владелец видит только свои строки
-- (при условии, что для него тоже существует запись в tenant_user)
-- Для административных операций используйте отдельного суперпользователя,
-- не связанного с бизнес-логикой приложения.
app_user без специальных привилегий, от имени которого работает приложение. Он подпадает под RLS. Второй — admin_user с правами BYPASSRLS, используемый только для административных задач (миграции, отладка, аналитика). Никогда не используйте суперпользователя для подключения приложения.
Альтернатива: переменные сессии вместо ролей PostgreSQL
Подход с отдельной ролью PostgreSQL на каждого арендатора имеет существенный недостаток: пул соединений (connection pool) сложно совместить с переключением ролей. Большинство пулеров (PgBouncer, HikariCP) работают с фиксированным набором соединений, и смена роли требует либо специальной настройки, либо дополнительных команд.
Более практичный подход для большинства приложений — хранить идентификатор текущего арендатора в переменной сессии PostgreSQL и использовать её в политиках RLS.
Политика на основе переменной сессии
-- Единственный пользователь приложения
CREATE USER app_user WITH PASSWORD 'app_secret';
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Политика читает текущего арендатора из переменной сессии,
-- а не из роли PostgreSQL
CREATE POLICY tenant_isolation_by_setting ON orders
USING (
tenant_id = current_setting('app.current_tenant')::INTEGER
)
WITH CHECK (
tenant_id = current_setting('app.current_tenant')::INTEGER
);
Установка переменной сессии из приложения
Перед выполнением бизнес-запросов приложение устанавливает переменную сессии для текущего арендатора. Это можно делать через set_config() или команду SET.
-- Вариант 1: через set_config (второй параметр false = только для транзакции)
SELECT set_config('app.current_tenant', '1', false);
-- Вариант 2: SET LOCAL — ограничено текущей транзакцией
BEGIN;
SET LOCAL app.current_tenant = '1';
SELECT * FROM orders; -- видит только заказы арендатора 1
COMMIT;
-- Вариант 3: SET — действует до конца сессии
SET app.current_tenant = '2';
SELECT * FROM orders; -- видит только заказы арендатора 2
app.current_tenant в начале каждой транзакции через SET LOCAL, а не SET. Это гарантирует, что переменная живёт не дольше транзакции и не "протечёт" к следующему пользователю пула.
Пример интеграции с Java (JDBC + HikariCP)
В Java-приложениях с пулом HikariCP можно настроить автоматическую установку переменной при получении соединения:
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
public class TenantDataSource {
private final HikariDataSource dataSource;
public TenantDataSource(String jdbcUrl, String user, String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(user);
config.setPassword(password);
// Дополнительный SQL выполняется при каждом получении соединения из пула
config.setConnectionInitSql("SET app.current_tenant = '0'");
this.dataSource = new HikariDataSource(config);
}
/**
* Возвращает соединение с установленным контекстом арендатора.
* Вызывать в начале каждого запроса.
*/
public Connection getConnectionForTenant(Long tenantId) throws SQLException {
Connection conn = dataSource.getConnection();
try (PreparedStatement ps = conn.prepareStatement(
"SELECT set_config('app.current_tenant', ?, false)")) {
ps.setString(1, tenantId.toString());
ps.execute();
}
return conn;
}
}
ActiveRecord::Base.connection.execute("SET LOCAL app.current_tenant = #{tenant_id}") внутри блока транзакции, или через callback around_action в ApplicationController, который оборачивает каждый запрос в транзакцию с установленной переменной.
Защита от пустой переменной сессии
Если переменная app.current_tenant не установлена, current_setting() по умолчанию выбросит ошибку. Это желательное поведение — запросы без контекста арендатора должны падать, а не возвращать все данные. Однако для административных запросов можно использовать второй параметр:
-- Вернёт NULL вместо ошибки, если переменная не установлена
current_setting('app.current_tenant', true)
-- В политике: если переменная не установлена — не показывать ничего
CREATE POLICY tenant_isolation_safe ON orders
USING (
tenant_id = NULLIF(
current_setting('app.current_tenant', true), ''
)::INTEGER
);
Преимущества и итоги
Что даёт RLS
- Централизованная защита на уровне БД: изоляция работает независимо от того, какой код выполняет запрос — приложение, ORM, аналитический инструмент или прямое подключение
- Прозрачность для разработчиков: код не нужно менять — запросы без явного
WHERE tenant_id = ?автоматически получат нужную фильтрацию - Защита от случайных ошибок: даже если разработчик забыл добавить условие фильтрации, RLS не позволит данным утечь
- Единая схема миграций: в отличие от Bridge-модели, где миграцию нужно применить к каждой схеме отдельно, в Pool-модели одна миграция покрывает всех арендаторов
Что нужно учитывать
- RLS добавляет подзапрос к каждому запросу — убедитесь, что есть индекс на
tenant_idв каждой таблице с политикой - Суперпользователи и пользователи с
BYPASSRLSобходят политики — управляйте привилегиями строго - При использовании пула соединений предпочтительнее
SET LOCALвнутри транзакции, а неSETдля сессии - Проверяйте планы выполнения запросов после включения RLS — дополнительные условия могут изменить выбор индекса
- Оптимизация запросов PostgreSQL — EXPLAIN ANALYZE, индексы, стратегии
- MVCC в PostgreSQL — как PostgreSQL управляет версиями строк
- Масштабирование PostgreSQL — репликация и шардирование