Изоляция данных в мультиарендных системах

PostgreSQL Row Level Security как инструмент защиты данных арендаторов

Мультиарендность (multi-tenancy) — один из базовых паттернов SaaS-архитектуры. Несколько клиентов (арендаторов) используют одну систему, и ключевая задача — гарантировать, что данные одного клиента никогда не попадут к другому. PostgreSQL Row Level Security (RLS) позволяет решить эту задачу на уровне самой базы данных.

RLS появился в PostgreSQL 9.5 (2016 год) и с тех пор является частью стандартного дистрибутива. Функциональность поддерживается во всех актуальных версиях PostgreSQL, включая managed-сервисы (AWS RDS, Google Cloud SQL, Azure Database for PostgreSQL, Supabase).

Три модели мультиарендности

Прежде чем говорить о 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
Модель Pool с RLS — наиболее распространённый выбор для зрелых SaaS-продуктов с большим количеством арендаторов. Именно её мы разберём подробно.

Что такое Row Level Security

Row Level Security — механизм PostgreSQL, позволяющий автоматически добавлять условия фильтрации к любому запросу на уровне ядра СУБД. Грубо говоря, это автоматический WHERE, который PostgreSQL добавляет к каждому SELECT, INSERT, UPDATE и DELETE перед выполнением.

Принципиальное отличие от фильтрации в приложении: RLS применяется всегда, независимо от того, какой код выполняет запрос. Даже если разработчик случайно забыл добавить WHERE tenant_id = ? в запрос, база данных добавит это условие сама.

Без RLS изоляция арендаторов полностью зависит от корректности кода приложения. Один пропущенный WHERE в любом из сотен запросов — и данные одного клиента уйдут другому. RLS переносит ответственность за изоляцию на уровень, который нельзя обойти случайно.

Как работают политики RLS

Политика RLS — это именованное правило, привязанное к таблице. Она состоит из двух частей:

Если для таблицы задано несколько политик, 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

Важно: пользователь приложения (application user), от имени которого Rails или любой другой фреймворк подключается к базе, не должен быть суперпользователем или владельцем таблиц. Иначе RLS не применяется.

Принудительное применение RLS для владельца

Если по архитектурным причинам приложение подключается от имени владельца таблицы, можно принудительно включить RLS даже для него:

-- Владелец таблицы тоже обязан соблюдать политики
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

-- Проверка: теперь даже владелец видит только свои строки
-- (при условии, что для него тоже существует запись в tenant_user)

-- Для административных операций используйте отдельного суперпользователя,
-- не связанного с бизнес-логикой приложения.
Архитектурная рекомендация: создайте минимум два пользователя PostgreSQL. Первый — 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
Пул соединений и переменные сессии: при использовании connection pool важно понимать, что переменная сессии сохраняется между запросами, если соединение возвращается в пул без сброса. Всегда устанавливайте 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;
    }
}
В Ruby on Rails аналогичного эффекта можно добиться через 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

Что нужно учитывать

Итог: модель Pool с Row Level Security — оптимальный выбор для большинства SaaS-продуктов. Она снижает стоимость инфраструктуры, упрощает операционные процессы и при этом обеспечивает надёжную изоляцию данных арендаторов на уровне, который нельзя случайно обойти в коде приложения.
Связанные материалы: