Multi-tenant SaaS обслуживает многих клиентов-арендаторов одной системой, и главное требование — данные одного арендатора никогда не видны другому. Самая экономная модель — общая схема с колонкой tenant_id во всех таблицах; в PostgreSQL её страхует Row-Level Security: политика по tenant_id фильтрует строки в самой базе, даже если в запросе приложения забыли условие. Отдельные схемы или базы на арендатора дают более сильную изоляцию ценой эксплуатации, а RLS обходят суперпользователь, роли с BYPASSRLS и владелец таблицы без FORCE.
После материала вы сможете
- сравнить общую схему с
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 г.
Содержание
- Что такое multi-tenancy и где граница арендатора
- Три модели изоляции и их цена
- tenant_id везде: схема, ключи и индексы
- Row-Level Security в PostgreSQL: политики USING и WITH CHECK
- Как передать арендатора в запрос: от HTTP до SET LOCAL
- Ловушки: кто и как обходит RLS
- Тесты на изоляцию
- Шумные соседи, лимиты и биллинг: обзор
Что такое 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.
-- 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 — плейсхолдер для локального стенда; в реальной системе он приходит из менеджера секретовtenant_idв каждой таблицеДаже в дочерних:
bookingsне выводит арендатора через соединение сclients, а хранит его сам. Иначе политику пришлось бы писать подзапросом, а документация PostgreSQL предупреждает, что политики, обращающиеся к другим строкам и таблицам, медленнее и могут создавать гонки с утечкой данных.Составной первичный ключ
PRIMARY KEY (tenant_id, id)делаетtenant_idпервой колонкой индекса — запросы внутри арендатора используют его естественным образом.Составной внешний ключ
FOREIGN KEY (tenant_id, client_id)гарантирует на уровне базы, что запись ссылается только на клиента своего салона. Простой ключ поclient_idпозволил бы сослаться на чужого клиента.Индексы начинаются с
tenant_idПочти каждый запрос фильтруется по арендатору, поэтому
(tenant_id, starts_at)полезнее, чем(starts_at). Уникальные ограничения тоже включаютtenant_id: телефон уникален внутри салона, а не во всей системе.Значение по умолчанию
DEFAULT app.current_tenant()подставляет арендатора текущей транзакции, и код вставки не передаётtenant_idвручную.
Функция app.current_tenant() объявлена STABLE и написана на SQL, поэтому планировщик подставляет её тело прямо в условие политики. Проверим это на объёме: добавим 200 арендаторов по 5 клиентов и 200 записей на клиента и посмотрим план запроса от имени приложения.
-- 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;$ 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, поэтому без арендатора политика не пропускает ни одной строки: система по умолчанию закрыта. Запустим стенд и выполним сценарий от имени приложения:
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.sqlbash start.sh (в Git Bash на Windows — MSYS_NO_PATHCONV=1 bash start.sh). sh -c "… 2>&1" в командах ниже объединяет stdout и stderr внутри контейнера, чтобы ошибки стояли на своих местах-- 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');-- 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;$ 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 для этого режима отмечены как неподдерживаемые. Настройка уровня транзакции живёт ровно столько, сколько соединение закреплено за клиентом, поэтому подходит для обоих видов пула.
{
"name": "tenant-isolation",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "node --test"
},
"dependencies": {
"pg": "^8.23.1"
}
}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. Проверим это и ещё две ловушки от имени суперпользователя:
-- 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;$ 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. Такой тест ловит ошибку в новой миграции раньше, чем она попадёт в продакшен.
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 });
});$ 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.