Как делать11 октября 2026 г.12 мин чтения
Архитектура ПОШаг 9 из 10

Multi-tenant SaaS: изоляция данных арендаторов

Коротко

Multi-tenant SaaS обслуживает многих клиентов-арендаторов одной системой, и главное требование — данные одного арендатора никогда не видны другому. Самая экономная модель — общая схема с колонкой tenant_id во всех таблицах; в PostgreSQL её страхует Row-Level Security: политика по tenant_id фильтрует строки в самой базе, даже если в запросе приложения забыли условие. Отдельные схемы или базы на арендатора дают более сильную изоляцию ценой эксплуатации, а RLS обходят суперпользователь, роли с BYPASSRLS и владелец таблицы без FORCE.

Глубокий разборУровень: ПродвинутыйМультитенантный SaaS

После материала вы сможете

  • сравнить общую схему с tenant_id, схему на арендатора и базу на арендатора по изоляции, стоимости, миграциям и шумным соседям
  • включить Row-Level Security в PostgreSQL с политиками USING и WITH CHECK, FORCE и отдельной ролью приложения без BYPASSRLS
  • передавать арендатора в запрос через set_config(..., true) так, чтобы он не утекал между запросами в пуле соединений
  • написать тесты на утечку между арендаторами и проверку, что ни одна таблица с tenant_id не осталась без RLS

Проверено на

PostgreSQL 17 (17.11, postgres:17-alpine)Node.js 24 LTS (24.18)node-postgres (pg) 8.23node:test

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что такое multi-tenancy и где граница арендатора
  2. 02Три модели изоляции и их цена
  3. 03tenant_id везде: схема, ключи и индексы
  4. 04Row-Level Security в PostgreSQL: политики USING и WITH CHECK
  5. 05Как передать арендатора в запрос: от HTTP до SET LOCAL
  6. 06Ловушки: кто и как обходит RLS
  7. 07Тесты на изоляцию
  8. 08Шумные соседи, лимиты и биллинг: обзор

Что такое multi-tenancy и где граница арендатора

Арендатор (tenant) — клиент SaaS, данные которого должны быть отделены от остальных. В B2B это обычно организация: Microsoft в руководстве по моделям арендной архитектуры предлагает сопоставлять арендатора с организацией-клиентом, а всех её сотрудников считать пользователями внутри одного арендатора. Сквозной пример этого разбора — сервис онлайн-записи для салонов: каждый салон — арендатор, его администраторы и мастера — пользователи, клиенты и записи салона — данные арендатора.

Изоляция арендаторов — не то же самое, что авторизация. Авторизация отвечает на вопрос «может ли этот пользователь сделать это действие», изоляция — «может ли вообще хоть что-то в запросе этого арендатора коснуться чужих строк». В общей базе одна забытая строчка WHERE tenant_id = … в отчёте превращается в утечку данных всех салонов, поэтому изоляцию строят так, чтобы она не зависела от внимательности автора каждого запроса.

  • Арендатор и развёртывание — разные понятия: Microsoft различает логических арендаторов и deployments (их ещё называют stamps). Один набор инфраструктуры может обслуживать многих арендаторов, а крупному клиенту можно выделить свой
  • Арендатор определяется на сервере по проверенным данным — подписанному токену, членству пользователя в организации, — а не по тому, что прислал клиент
  • Каждая операция над данными выполняется в контексте ровно одного арендатора; операции «по всем» (аналитика, биллинг) — отдельный, явно выделенный путь

Три модели изоляции и их цена

AWS в SaaS Lens описывает три шаблона: silo — у арендатора выделенные ресурсы (свой стек или своя база), pool — арендаторы делят общие ресурсы, bridge — смешанный вариант, где часть системы выделенная, часть общая. Microsoft советует смотреть на изоляцию как на шкалу от «всё общее» до «ничего общего» и выбирать уровень для каждого слоя отдельно. Для слоя данных в PostgreSQL на этой шкале три типовые точки:

Изоляция

Общая схема + tenant_id (pool)
Логическая: tenant_id и политики RLS
Схема на арендатора
Отдельные объекты; доступ разграничивают search_path и права
База на арендатора (silo)
Физически разные базы или серверы

Стоимость на арендатора

Общая схема + tenant_id (pool)
Минимальная: новый арендатор — одна строка
Схема на арендатора
Растёт с числом схем
База на арендатора (silo)
Максимальная: отдельная база, бэкапы, мониторинг

Миграции

Общая схема + tenant_id (pool)
Одна на всех
Схема на арендатора
Повторяются для каждой схемы
База на арендатора (silo)
Повторяются для каждой базы, нужна оркестрация

Отчёты по всем арендаторам

Общая схема + tenant_id (pool)
Обычный запрос (с отдельной ролью)
Схема на арендатора
Объединение по схемам
База на арендатора (silo)
Сбор данных из многих баз

Шумный сосед

Общая схема + tenant_id (pool)
Делят CPU, память и ввод-вывод
Схема на арендатора
Делят тот же сервер
База на арендатора (silo)
Изолирован, если базы на разных серверах

Бэкап и восстановление одного арендатора

Общая схема + tenant_id (pool)
Выборочно, по tenant_id
Схема на арендатора
Дамп одной схемы
База на арендатора (silo)
Дамп одной базы

Требования к месту хранения данных

Общая схема + tenant_id (pool)
Сложно: все данные в одном месте
Схема на арендатора
Сложно
База на арендатора (silo)
Проще: базу размещают там, где требует клиент

Microsoft перечисляет риски полностью общей модели: данные нужно аккуратно разделять, крупный арендатор с тяжёлыми запросами влияет на остальных, а изменение задевает сразу всех клиентов. Риски выделенной модели — низкая экономическая эффективность (100 арендаторов — примерно 100 раз та же стоимость инфраструктуры) и трудоёмкое обслуживание множества копий. Схема на арендатора — промежуточный вариант; инженерный компромисс в том, что каждая миграция выполняется столько раз, сколько схем, и при тысячах схем растут каталог базы и время развёртывания.

Общая схема — когда подходит

  • Много небольших арендаторов с похожей нагрузкой
  • Одинаковая модель данных у всех клиентов
  • Нужны быстрые подключения новых арендаторов
  • Нет требования клиента держать данные физически отдельно

Отдельная база — когда подходит

  • Мало крупных клиентов с требованиями регуляторов или договора
  • Клиенту нужны свои окна обслуживания, версия или регион
  • Нагрузка одного арендатора сопоставима со всей системой
  • Клиент готов оплачивать выделенную инфраструктуру

По нашему опыту, для SaaS малого и среднего бизнеса разумный старт — общая схема с RLS, спроектированная так, чтобы крупного арендатора можно было позже перенести в отдельную базу (модель bridge у AWS, вертикальное разделение у Microsoft). Перенос возможен, только если tenant_id с первого дня есть в каждой таблице — этим и займёмся.

tenant_id везде: схема, ключи и индексы

Схема ниже — вся основа разбора. Её выполняет суперпользователь один раз, как миграцию. Таблицами владеет роль app_owner без права входа, приложение подключается ролью app_user: она не суперпользователь и не имеет атрибута BYPASSRLS.

sql/01-schema.sql
-- Run as a superuser once (migration). The app never connects as owner or superuser.
CREATE ROLE app_owner NOLOGIN;
CREATE ROLE app_user LOGIN PASSWORD 'change-me' NOSUPERUSER NOBYPASSRLS;

CREATE SCHEMA app AUTHORIZATION app_owner;
GRANT USAGE ON SCHEMA app TO app_user;
SET ROLE app_owner;

-- Tenant of the current transaction; NULL when not set, so policies match nothing.
CREATE FUNCTION app.current_tenant() RETURNS uuid
  LANGUAGE sql STABLE
  AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;

CREATE TABLE app.tenants (
  id   uuid PRIMARY KEY,
  name text NOT NULL,
  plan text NOT NULL DEFAULT 'basic'
);

CREATE TABLE app.clients (
  tenant_id uuid NOT NULL DEFAULT app.current_tenant() REFERENCES app.tenants (id),
  id        uuid NOT NULL DEFAULT gen_random_uuid(),
  name      text NOT NULL,
  PRIMARY KEY (tenant_id, id)
);

CREATE TABLE app.bookings (
  tenant_id uuid NOT NULL DEFAULT app.current_tenant(),
  id        uuid NOT NULL DEFAULT gen_random_uuid(),
  client_id uuid NOT NULL,
  starts_at timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, id),
  -- composite key: a booking cannot point at another tenant's client
  FOREIGN KEY (tenant_id, client_id) REFERENCES app.clients (tenant_id, id)
);
CREATE INDEX bookings_tenant_starts_idx ON app.bookings (tenant_id, starts_at);

ALTER TABLE app.tenants  ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.clients  ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.bookings ENABLE ROW LEVEL SECURITY;
-- FORCE: the table owner is subject to the policies too
ALTER TABLE app.tenants  FORCE ROW LEVEL SECURITY;
ALTER TABLE app.clients  FORCE ROW LEVEL SECURITY;
ALTER TABLE app.bookings FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_self ON app.tenants
  USING (id = app.current_tenant());
CREATE POLICY tenant_isolation ON app.clients
  USING (tenant_id = app.current_tenant())
  WITH CHECK (tenant_id = app.current_tenant());
CREATE POLICY tenant_isolation ON app.bookings
  USING (tenant_id = app.current_tenant())
  WITH CHECK (tenant_id = app.current_tenant());

GRANT SELECT ON app.tenants TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON app.clients, app.bookings TO app_user;
RESET ROLE;
Пароль change-me — плейсхолдер для локального стенда; в реальной системе он приходит из менеджера секретов
  1. tenant_id в каждой таблице

    Даже в дочерних: bookings не выводит арендатора через соединение с clients, а хранит его сам. Иначе политику пришлось бы писать подзапросом, а документация PostgreSQL предупреждает, что политики, обращающиеся к другим строкам и таблицам, медленнее и могут создавать гонки с утечкой данных.

  2. Составной первичный ключ

    PRIMARY KEY (tenant_id, id) делает tenant_id первой колонкой индекса — запросы внутри арендатора используют его естественным образом.

  3. Составной внешний ключ

    FOREIGN KEY (tenant_id, client_id) гарантирует на уровне базы, что запись ссылается только на клиента своего салона. Простой ключ по client_id позволил бы сослаться на чужого клиента.

  4. Индексы начинаются с tenant_id

    Почти каждый запрос фильтруется по арендатору, поэтому (tenant_id, starts_at) полезнее, чем (starts_at). Уникальные ограничения тоже включают tenant_id: телефон уникален внутри салона, а не во всей системе.

  5. Значение по умолчанию

    DEFAULT app.current_tenant() подставляет арендатора текущей транзакции, и код вставки не передаёт tenant_id вручную.

Функция app.current_tenant() объявлена STABLE и написана на SQL, поэтому планировщик подставляет её тело прямо в условие политики. Проверим это на объёме: добавим 200 арендаторов по 5 клиентов и 200 записей на клиента и посмотрим план запроса от имени приложения.

sql/05-explain.sql
-- Run as the superuser: 200 more tenants, 5 clients each, 200 bookings per client
INSERT INTO app.tenants (id, name)
  SELECT gen_random_uuid(), 'Tenant ' || g FROM generate_series(1, 200) g;
INSERT INTO app.clients (tenant_id, name)
  SELECT t.id, 'Client ' || g FROM app.tenants t, generate_series(1, 5) g;
INSERT INTO app.bookings (tenant_id, client_id, starts_at)
  SELECT c.tenant_id, c.id, '2026-11-01 09:00+05'::timestamptz + g * interval '1 hour'
    FROM app.clients c, generate_series(1, 200) g;
ANALYZE app.tenants, app.clients, app.bookings;

SET ROLE app_user;
BEGIN;
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
EXPLAIN (COSTS OFF)
  SELECT * FROM app.bookings
   WHERE starts_at >= '2026-11-02' AND starts_at < '2026-11-03'
   ORDER BY starts_at;
COMMIT;
RESET ROLE;
terminal
$ docker exec kb-tenants sh -c "psql -U postgres -d saas -X -e -f /sql/05-explain.sql 2>&1"
…
EXPLAIN (COSTS OFF)
  SELECT * FROM app.bookings
   WHERE starts_at >= '2026-11-02' AND starts_at < '2026-11-03'
   ORDER BY starts_at;
                                                                                                                         QUERY PLAN                                                                                                                          
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Sort
   Sort Key: starts_at
   ->  Bitmap Heap Scan on bookings
         Recheck Cond: ((tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::uuid) AND (starts_at >= '2026-11-02 00:00:00+00'::timestamp with time zone) AND (starts_at < '2026-11-03 00:00:00+00'::timestamp with time zone))
         ->  Bitmap Index Scan on bookings_tenant_starts_idx
               Index Cond: ((tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::uuid) AND (starts_at >= '2026-11-02 00:00:00+00'::timestamp with time zone) AND (starts_at < '2026-11-03 00:00:00+00'::timestamp with time zone))
(6 rows)
Фрагмент вывода: условие политики стало частью Index Cond по индексу (tenant_id, starts_at)

Row-Level Security в PostgreSQL: политики USING и WITH CHECK

Row-Level Security (RLS, защита на уровне строк) включается командой ALTER TABLE … ENABLE ROW LEVEL SECURITY. После этого, по документации PostgreSQL, любое обычное чтение и изменение строк должно быть разрешено политикой; если политик нет, действует запрет по умолчанию — не видно и нельзя изменить ни одной строки. Операции над таблицей целиком, например TRUNCATE, под RLS не попадают.

USING

Что делает
Какие существующие строки видны и доступны для UPDATE и DELETE; false или NULL — строка невидима, без ошибки
В нашей схеме
tenant_id = app.current_tenant()

WITH CHECK

Что делает
Каким условиям должна отвечать новая строка после INSERT или UPDATE; false или NULL — ошибка
В нашей схеме
То же условие: нельзя записать строку в чужого арендатора

Permissive (по умолчанию)

Что делает
Политики объединяются через OR
В нашей схеме
Одна политика на таблицу

Restrictive

Что делает
Объединяются через AND поверх permissive
В нашей схеме
Не нужны; пригодятся, например, для «только чтение для заблокированных арендаторов»

FORCE ROW LEVEL SECURITY

Что делает
Политики действуют и на владельца таблицы
В нашей схеме
Включено на всех трёх таблицах

Если app.tenant_id не задан, current_setting('app.tenant_id', true) возвращает NULL (второй аргумент true — «не падать, если параметра нет»), а после завершения транзакции — пустую строку; nullif превращает её обратно в NULL. Сравнение с NULL не даёт true, поэтому без арендатора политика не пропускает ни одной строки: система по умолчанию закрыта. Запустим стенд и выполним сценарий от имени приложения:

start.sh
docker run -d --rm --name kb-tenants -e POSTGRES_PASSWORD=test \
  -p 127.0.0.1:55439:5432 -v "$PWD/sql:/sql:ro" postgres:17-alpine
sleep 5
docker exec kb-tenants psql -U postgres -X -qc "CREATE DATABASE saas"
docker exec kb-tenants psql -U postgres -d saas -X -v ON_ERROR_STOP=1 -f /sql/01-schema.sql
docker exec kb-tenants psql -U postgres -d saas -X -v ON_ERROR_STOP=1 -f /sql/02-seed.sql
Стенд в Docker: запускается из папки проекта командой bash start.sh (в Git Bash на Windows — MSYS_NO_PATHCONV=1 bash start.sh). sh -c "… 2>&1" в командах ниже объединяет stdout и stderr внутри контейнера, чтобы ошибки стояли на своих местах
sql/02-seed.sql
-- Test data, loaded by the superuser (it bypasses RLS).
INSERT INTO app.tenants (id, name) VALUES
  ('11111111-1111-4111-8111-111111111111', 'Lavender Salon'),
  ('22222222-2222-4222-8222-222222222222', 'Mint Barbershop');

INSERT INTO app.clients (tenant_id, id, name) VALUES
  ('11111111-1111-4111-8111-111111111111', 'aaaaaaaa-0000-4000-8000-000000000001', 'Aigerim'),
  ('11111111-1111-4111-8111-111111111111', 'aaaaaaaa-0000-4000-8000-000000000002', 'Dana'),
  ('22222222-2222-4222-8222-222222222222', 'bbbbbbbb-0000-4000-8000-000000000001', 'Timur');

INSERT INTO app.bookings (tenant_id, client_id, starts_at) VALUES
  ('11111111-1111-4111-8111-111111111111', 'aaaaaaaa-0000-4000-8000-000000000001', '2026-11-02 10:00+05'),
  ('22222222-2222-4222-8222-222222222222', 'bbbbbbbb-0000-4000-8000-000000000001', '2026-11-02 11:00+05');
sql/03-isolation.sql
-- Run as app_user: psql -U app_user -d saas -f 03-isolation.sql
\set A '11111111-1111-4111-8111-111111111111'
\set B '22222222-2222-4222-8222-222222222222'

-- 1. No tenant in the transaction: nothing is visible
SELECT count(*) AS clients_without_tenant FROM app.clients;

-- 2. Tenant A sees only its own rows, even when asking for B explicitly
BEGIN;
SELECT set_config('app.tenant_id', :'A', true);
SELECT t.name AS tenant, c.name AS client
  FROM app.clients c JOIN app.tenants t ON t.id = c.tenant_id
  ORDER BY c.name;
SELECT count(*) AS rows_of_b FROM app.clients WHERE tenant_id = :'B';
UPDATE app.clients SET name = 'Hacked' WHERE tenant_id = :'B';
DELETE FROM app.bookings WHERE tenant_id = :'B';
COMMIT;

-- 3. Writing into another tenant is rejected by WITH CHECK
BEGIN;
SELECT set_config('app.tenant_id', :'A', true);
INSERT INTO app.clients (tenant_id, name) VALUES (:'B', 'Mallory');
ROLLBACK;

-- 4. tenant_id defaults to the current tenant; a booking cannot point at B's client
BEGIN;
SELECT set_config('app.tenant_id', :'A', true);
INSERT INTO app.clients (name) VALUES ('Saule') RETURNING tenant_id, name;
INSERT INTO app.bookings (client_id, starts_at)
  VALUES ('bbbbbbbb-0000-4000-8000-000000000001', '2026-11-03 12:00+05');
ROLLBACK;

-- 5. The setting ends with the transaction
SELECT current_setting('app.tenant_id', true) AS after_commit,
       (SELECT count(*) FROM app.clients) AS visible_clients;
terminal
$ docker exec kb-tenants sh -c "psql -U app_user -d saas -X -e -f /sql/03-isolation.sql 2>&1"
SELECT count(*) AS clients_without_tenant FROM app.clients;
 clients_without_tenant 
------------------------
                      0
(1 row)

BEGIN;
BEGIN
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
              set_config              
--------------------------------------
 11111111-1111-4111-8111-111111111111
(1 row)

SELECT t.name AS tenant, c.name AS client
  FROM app.clients c JOIN app.tenants t ON t.id = c.tenant_id
  ORDER BY c.name;
     tenant     | client  
----------------+---------
 Lavender Salon | Aigerim
 Lavender Salon | Dana
(2 rows)

SELECT count(*) AS rows_of_b FROM app.clients WHERE tenant_id = '22222222-2222-4222-8222-222222222222';
 rows_of_b 
-----------
         0
(1 row)

UPDATE app.clients SET name = 'Hacked' WHERE tenant_id = '22222222-2222-4222-8222-222222222222';
UPDATE 0
DELETE FROM app.bookings WHERE tenant_id = '22222222-2222-4222-8222-222222222222';
DELETE 0
COMMIT;
COMMIT
BEGIN;
BEGIN
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
              set_config              
--------------------------------------
 11111111-1111-4111-8111-111111111111
(1 row)

INSERT INTO app.clients (tenant_id, name) VALUES ('22222222-2222-4222-8222-222222222222', 'Mallory');
psql:/sql/03-isolation.sql:22: ERROR:  new row violates row-level security policy for table "clients"
ROLLBACK;
ROLLBACK
BEGIN;
BEGIN
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
              set_config              
--------------------------------------
 11111111-1111-4111-8111-111111111111
(1 row)

INSERT INTO app.clients (name) VALUES ('Saule') RETURNING tenant_id, name;
              tenant_id               | name  
--------------------------------------+-------
 11111111-1111-4111-8111-111111111111 | Saule
(1 row)

INSERT 0 1
INSERT INTO app.bookings (client_id, starts_at)
  VALUES ('bbbbbbbb-0000-4000-8000-000000000001', '2026-11-03 12:00+05');
psql:/sql/03-isolation.sql:30: ERROR:  insert or update on table "bookings" violates foreign key constraint "bookings_tenant_id_client_id_fkey"
DETAIL:  Key is not present in table "clients".
ROLLBACK;
ROLLBACK
SELECT current_setting('app.tenant_id', true) AS after_commit,
       (SELECT count(*) FROM app.clients) AS visible_clients;
 after_commit | visible_clients 
--------------+-----------------
              |               0
(1 row)
Фактический вывод psql -e: запросы и результаты по порядку, ошибки — в том месте, где они возникли
  • Шаг 1: без арендатора — 0 строк, хотя в таблице три клиента
  • Шаг 2: арендатор A видит только своих клиентов; явный запрос строк B возвращает 0, UPDATE и DELETE чужих строк затрагивают 0 строк — без ошибки, строки просто невидимы
  • Шаг 3: вставка с чужим tenant_id отклонена политикой WITH CHECK
  • Шаг 4: tenant_id подставился сам; запись на клиента салона B не прошла по составному внешнему ключу. PostgreSQL при этом не показал значения ключа в DETAIL — пользователь не должен узнавать данные, которых не видит
  • Шаг 5: после ROLLBACK параметр пуст, строки снова не видны

Как передать арендатора в запрос: от HTTP до SET LOCAL

Сначала приложение определяет арендатора по HTTP-запросу. Способы различаются степенью доверия:

Поддомен

Пример
lavender.example.app
Чему можно доверять
Говорит, к какому салону обращаются, но не доказывает права: нужно проверить, что вошедший пользователь — член этого арендатора

Утверждение в токене

Пример
tenant_id в подписанном JWT или в серверной сессии
Чему можно доверять
Подпись или сессия выпущены сервером при входе — самый надёжный вариант

Заголовок

Пример
X-Tenant-Id: …
Чему можно доверять
Только если его ставит доверенный шлюз или значение сверяется с членством пользователя; иначе любой клиент подставит чужой идентификатор

Дальше арендатор попадает в базу. Ключевой выбор — время жизни настройки. SET без LOCAL действует до конца сессии, то есть до закрытия соединения. В пуле соединений одно соединение по очереди обслуживает разные HTTP-запросы, и забытая настройка прошлого запроса достанется следующему — в разделе о ловушках это показано на практике. SET LOCAL и set_config(name, value, true) действуют только до конца текущей транзакции: после COMMIT или ROLLBACK значение сбрасывается. Функция удобнее команды: её можно вызвать с параметром $1, не склеивая SQL из строки.

Внешний пул PgBouncer в режиме transaction pooling выдаёт серверное соединение клиенту только на время транзакции, и в его таблице совместимости SET/RESET для этого режима отмечены как неподдерживаемые. Настройка уровня транзакции живёт ровно столько, сколько соединение закреплено за клиентом, поэтому подходит для обоих видов пула.

package.json
{
  "name": "tenant-isolation",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --test"
  },
  "dependencies": {
    "pg": "^8.23.1"
  }
}
src/db.js
import pg from "pg";

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 });

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// Every tenant query runs in a transaction with a transaction-local tenant:
// set_config(..., true) is reset on COMMIT/ROLLBACK, so a pooled connection
// never carries the previous request's tenant.
export async function withTenant(tenantId, fn, db = pool) {
  if (!UUID.test(tenantId)) throw new Error("withTenant: tenantId must be a UUID");
  const client = await db.connect();
  try {
    await client.query("BEGIN");
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
    const result = await fn(client);
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK").catch(() => {});
    throw err;
  } finally {
    client.release();
  }
}
Обёртка над pg.Pool: весь код, работающий с данными арендатора, получает соединение только через withTenant

Ловушки: кто и как обходит RLS

Документация PostgreSQL прямо говорит: суперпользователи и роли с атрибутом BYPASSRLS всегда обходят защиту на уровне строк, владелец таблицы обычно тоже — если не включить FORCE ROW LEVEL SECURITY. Проверим это и ещё две ловушки от имени суперпользователя:

sql/04-pitfalls.sql
-- Run as the superuser: psql -U postgres -d saas -f 04-pitfalls.sql
-- 1. Superusers bypass RLS: every tenant is visible
SELECT current_user, count(*) AS clients FROM app.clients;

-- 2. So does any role with BYPASSRLS
CREATE ROLE reporting LOGIN BYPASSRLS;
GRANT USAGE ON SCHEMA app TO reporting;
GRANT SELECT ON app.clients TO reporting;
SET ROLE reporting;
SELECT current_user, count(*) AS clients FROM app.clients;
RESET ROLE;

-- 3. Without FORCE, the table owner bypasses RLS
ALTER TABLE app.clients NO FORCE ROW LEVEL SECURITY;
SET ROLE app_owner;
SELECT current_user, count(*) AS clients_no_force FROM app.clients;
RESET ROLE;
ALTER TABLE app.clients FORCE ROW LEVEL SECURITY;
SET ROLE app_owner;
SELECT current_user, count(*) AS clients_force FROM app.clients;
RESET ROLE;

-- 4. A view checks RLS as its owner: a superuser-owned view leaks everything
CREATE VIEW app.client_names AS SELECT tenant_id, name FROM app.clients;
GRANT SELECT ON app.client_names TO app_user;
SET ROLE app_user;
SELECT count(*) AS via_view_without_tenant FROM app.client_names;
RESET ROLE;
ALTER VIEW app.client_names SET (security_invoker = true);
SET ROLE app_user;
SELECT count(*) AS via_invoker_view FROM app.client_names;
RESET ROLE;

-- 5. Session-level SET survives the "request" on a pooled connection
SET ROLE app_user;
SET app.tenant_id = '11111111-1111-4111-8111-111111111111';   -- request 1, tenant A
SELECT count(*) AS request_1 FROM app.clients;
-- request 2 for another tenant forgets to set its tenant and reuses the connection
SELECT count(*) AS request_2_sees_a FROM app.clients;
RESET app.tenant_id;
RESET ROLE;
terminal
$ docker exec kb-tenants sh -c "psql -U postgres -d saas -X -e -f /sql/04-pitfalls.sql 2>&1"
SELECT current_user, count(*) AS clients FROM app.clients;
 current_user | clients 
--------------+---------
 postgres     |       3
(1 row)

CREATE ROLE reporting LOGIN BYPASSRLS;
CREATE ROLE
GRANT USAGE ON SCHEMA app TO reporting;
GRANT
GRANT SELECT ON app.clients TO reporting;
GRANT
SET ROLE reporting;
SET
SELECT current_user, count(*) AS clients FROM app.clients;
 current_user | clients 
--------------+---------
 reporting    |       3
(1 row)

RESET ROLE;
RESET
ALTER TABLE app.clients NO FORCE ROW LEVEL SECURITY;
ALTER TABLE
SET ROLE app_owner;
SET
SELECT current_user, count(*) AS clients_no_force FROM app.clients;
 current_user | clients_no_force 
--------------+------------------
 app_owner    |                3
(1 row)

RESET ROLE;
RESET
ALTER TABLE app.clients FORCE ROW LEVEL SECURITY;
ALTER TABLE
SET ROLE app_owner;
SET
SELECT current_user, count(*) AS clients_force FROM app.clients;
 current_user | clients_force 
--------------+---------------
 app_owner    |             0
(1 row)

RESET ROLE;
RESET
CREATE VIEW app.client_names AS SELECT tenant_id, name FROM app.clients;
CREATE VIEW
GRANT SELECT ON app.client_names TO app_user;
GRANT
SET ROLE app_user;
SET
SELECT count(*) AS via_view_without_tenant FROM app.client_names;
 via_view_without_tenant 
-------------------------
                       3
(1 row)

RESET ROLE;
RESET
ALTER VIEW app.client_names SET (security_invoker = true);
ALTER VIEW
SET ROLE app_user;
SET
SELECT count(*) AS via_invoker_view FROM app.client_names;
 via_invoker_view 
------------------
                0
(1 row)

RESET ROLE;
RESET
SET ROLE app_user;
SET
SET app.tenant_id = '11111111-1111-4111-8111-111111111111';
SET
SELECT count(*) AS request_1 FROM app.clients;
 request_1 
-----------
         2
(1 row)

SELECT count(*) AS request_2_sees_a FROM app.clients;
 request_2_sees_a 
------------------
                2
(1 row)

RESET app.tenant_id;
RESET
RESET ROLE;
RESET
Фактический вывод на том же стенде

Приложение подключается суперпользователем

Что видно в выводе
postgres видит всех 3 клиентов
Защита
Отдельная роль NOSUPERUSER NOBYPASSRLS для приложения

Роль с BYPASSRLS для отчётов или поддержки

Что видно в выводе
reporting видит всех
Защита
Выдавать только туда, где «все арендаторы» — осознанное требование, и логировать доступ

Владелец без FORCE

Что видно в выводе
app_owner: 3 строки без FORCE, 0 с FORCE
Защита
FORCE ROW LEVEL SECURITY на каждой таблице

Представление проверяет права владельца

Что видно в выводе
Представление суперпользователя отдало app_user все 3 строки без арендатора
Защита
security_invoker = true (по документации CREATE VIEW политики тогда применяются к вызывающему)

SET уровня сессии в пуле

Что видно в выводе
«Запрос 2» без своего арендатора увидел 2 строки A
Защита
Только set_config(..., true) или SET LOCAL внутри транзакции
  • pg_dump по умолчанию выставляет row_security = off: с этим параметром запрос, к которому применилась бы политика, не фильтруется, а падает с ошибкой. Поэтому бэкапы снимают ролью, которая обходит RLS, — и хранят их как данные всех арендаторов сразу
  • Миграции выполняются владельцем схемы; с FORCE миграция данных должна сама задавать арендатора или идти от роли, которой это явно разрешено
  • Фоновые задачи «по всем арендаторам» удобнее писать циклом: для каждого арендатора — своя транзакция с withTenant, а не одна роль с BYPASSRLS
  • Функции с SECURITY DEFINER выполняются с правами владельца и могут вернуть строки в обход политик вызывающего — их стоит перечислить и проверить на ревью

Тесты на изоляцию

Microsoft советует проверять выбранную модель тестами: данные одного арендатора не должны случайно попадать к другому. Ниже — набор на node:test, который подключается ролью приложения к стенду из прошлых разделов. Последний тест — «страж»: он читает системный каталог и падает, если в схеме появилась таблица с tenant_id без включённого и принудительного RLS, представление без security_invoker или если роль приложения получила BYPASSRLS. Такой тест ловит ошибку в новой миграции раньше, чем она попадёт в продакшен.

test/isolation.test.js
import { test, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
import { pool, withTenant } from "../src/db.js";

const A = "11111111-1111-4111-8111-111111111111";
const B = "22222222-2222-4222-8222-222222222222";
const AIGERIM = "aaaaaaaa-0000-4000-8000-000000000001";

after(() => pool.end());

const names = (tenant) =>
  withTenant(tenant, async (db) =>
    (await db.query("SELECT name FROM app.clients ORDER BY name")).rows.map((r) => r.name));

test("each tenant sees only its own clients", async () => {
  assert.deepEqual(await names(A), ["Aigerim", "Dana"]);
  assert.deepEqual(await names(B), ["Timur"]);
});

test("tenant B cannot read tenant A's row even by primary key", async () => {
  const rows = await withTenant(B, async (db) =>
    (await db.query("SELECT * FROM app.clients WHERE id = $1", [AIGERIM])).rows);
  assert.deepEqual(rows, []);
});

test("writing a row into another tenant is rejected (42501)", async () => {
  await assert.rejects(
    withTenant(A, (db) =>
      db.query("INSERT INTO app.clients (tenant_id, name) VALUES ($1, 'Mallory')", [B])),
    { code: "42501" },
  );
});

test("without a tenant nothing is visible", async () => {
  const { rows } = await pool.query("SELECT count(*)::int AS n FROM app.clients");
  assert.equal(rows[0].n, 0);
});

test("a pooled connection does not carry the tenant into the next request", async () => {
  const single = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
  try {
    await withTenant(A, (db) => db.query("SELECT 1"), single);
    const { rows } = await single.query(
      "SELECT current_setting('app.tenant_id', true) AS t, (SELECT count(*)::int FROM app.clients) AS n");
    assert.deepEqual(rows[0], { t: "", n: 0 });
  } finally {
    await single.end();
  }
});

test("concurrent requests of different tenants stay isolated", async () => {
  const seen = await Promise.all(
    Array.from({ length: 40 }, (_, i) => {
      const tenant = i % 2 ? A : B;
      return withTenant(tenant, async (db) => {
        await db.query("SELECT pg_sleep(0.01)");
        const { rows } = await db.query("SELECT DISTINCT tenant_id FROM app.clients");
        return { tenant, ids: rows.map((r) => r.tenant_id) };
      });
    }),
  );
  for (const { tenant, ids } of seen) assert.deepEqual(ids, [tenant]);
});

test("guard: RLS is enabled and forced everywhere, the app role cannot bypass it", async () => {
  const unprotected = await pool.query(`
    SELECT c.relname
      FROM pg_class c
      JOIN pg_namespace n ON n.oid = c.relnamespace
      JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND NOT a.attisdropped
     WHERE n.nspname = 'app' AND c.relkind = 'r'
       AND NOT (c.relrowsecurity AND c.relforcerowsecurity)`);
  assert.deepEqual(unprotected.rows, []);

  const ownerViews = await pool.query(`
    SELECT c.relname
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
     WHERE n.nspname = 'app' AND c.relkind = 'v'
       AND NOT coalesce('security_invoker=true' = ANY (c.reloptions), false)`);
  assert.deepEqual(ownerViews.rows, []);

  const role = await pool.query(
    "SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user");
  assert.deepEqual(role.rows[0], { rolsuper: false, rolbypassrls: false });
});
terminal
$ DATABASE_URL=postgres://app_user:change-me@127.0.0.1:55439/saas npm test
> tenant-isolation@1.0.0 test
> node --test

✔ each tenant sees only its own clients (19.0221ms)
✔ tenant B cannot read tenant A's row even by primary key (2.6029ms)
✔ writing a row into another tenant is rejected (42501) (2.7856ms)
✔ without a tenant nothing is visible (1.1808ms)
✔ a pooled connection does not carry the tenant into the next request (9.664ms)
✔ concurrent requests of different tenants stay isolated (109.7941ms)
✔ guard: RLS is enabled and forced everywhere, the app role cannot bypass it (5.9816ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 268.1635

Чтобы тесты не были формальностью, мы проверили их мутациями на том же стенде:

set_config(..., false) вместо true в withTenant

Что упало
Тесты пула и «без арендатора»: настройка пережила транзакцию

NO FORCE ROW LEVEL SECURITY на bookings

Что упало
Тест-страж

Представление без security_invoker

Что упало
Тест-страж

Дополнительная политика FOR SELECT USING (true)

Что упало
Пять тестов на чтение: permissive-политики объединяются через OR

ALTER ROLE app_user BYPASSRLS

Что упало
Все семь тестов
  • В каждой таблице с данными арендатора есть tenant_id, первичные и внешние ключи составные
  • RLS включён и принудительный (FORCE) на каждой такой таблице; политики USING и WITH CHECK
  • Приложение подключается ролью без SUPERUSER и BYPASSRLS, владелец таблиц — другая роль
  • Арендатор передаётся только через set_config(..., true) или SET LOCAL внутри транзакции
  • Представления создаются с security_invoker = true
  • Тест-страж по системному каталогу и тесты на утечку запускаются в CI

Шумные соседи, лимиты и биллинг: обзор

Изоляция данных — не единственная задача общей модели. Шумный сосед (noisy neighbor) — арендатор, чья нагрузка ухудшает работу остальных: Microsoft относит это к рискам разделяемой инфраструктуры, а меньшее влияние арендаторов друг на друга — к преимуществам выделенной. Инструменты защиты располагаются на разных уровнях:

API

Инструмент
Лимиты запросов на арендатора и тариф, ответ 429 с Retry-After
Замечание
Ключ лимита — tenant_id, а не IP-адрес

Приложение

Инструмент
Квоты тарифа: число мастеров, записей, размер файлов
Замечание
Тариф хранится у арендатора (plan) и проверяется на сервере

База данных

Инструмент
statement_timeout для роли приложения, пагинация, индексы с tenant_id
Замечание
Таймаут прерывает любой слишком долгий запрос, но действует на роль, а не на конкретного арендатора

Фоновые задачи

Инструмент
Очередь с честным распределением между арендаторами
Замечание
Иначе большой импорт одного салона задержит уведомления остальных

Инфраструктура

Инструмент
Перенос крупного арендатора в отдельную базу
Замечание
Модель bridge: общий код, выделенные данные

Биллинг и учёт потребления Microsoft разбирает отдельно: в общей базе трудно точно измерить, сколько ресурсов съел каждый арендатор. Варианты — индикативная метрика (например, объём данных арендатора), счёт ключевых операций (созданные записи), счёт запросов к API или оценка доли от общей стоимости. Для биллинга метрики хранят в надёжном хранилище, а не в системе телеметрии с выборочной записью: Microsoft предупреждает, что такие системы отбрасывают часть данных.

В нашей схеме естественная единица — созданная запись: событие с tenant_id пишется в таблицу учёта в той же транзакции, что и сама запись, и тогда счёт не расходится с данными. Это совет, а не стандарт: метрику выбирают под модель ценообразования продукта.

Как проверено2 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    sql/01-schema.sql, 02-seed.sql, 03-isolation.sql, 04-pitfalls.sql, 05-explain.sql, start.sh

    docker postgres:17-alpine (PostgreSQL 17.11), start.sh выполнен как есть (Git Bash, MSYS_NO_PATHCONV=1), контейнер kb-tenants; 03 — от app_user, 04 и 05 — от postgres через psql -e, stdout/stderr слиты внутри контейнера. Три прогона на свежих контейнерах дали побайтно одинаковый вывод 03 и 04 (md5 совпадают). В статье вывод без правки; для 05 — фрагмент с EXPLAIN

  • запущено, работает

    package.json, src/db.js, test/isolation.test.js

    Node.js 24.18.0, pg 8.23.1, node --test против стенда (роль app_user по TCP): 7/7 до и после 04-pitfalls. Мутации: set_config(..., false) — падают 2 теста; NO FORCE на bookings — страж; view без security_invoker — страж; доп. политика USING (true) — 5 тестов; app_user BYPASSRLS — 7 тестов

  • не запускалось

    упражнения 1 и 2

    решения не публикуются, только подсказки; подсказки сверены с CREATE POLICY (INSERT — только WITH CHECK, UPDATE — оба выражения; restrictive объединяются через AND)

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.