В одном из прошлых проектов случился «кошмар техлида»: в суматохе хотфикса было забыто добавление фильтра WHERE tenant_id = ? в одну из ручек API. В итоге один клиент увидел отчеты другого. Все быстро откатили, но я навсегда запомнил то холодное чувство в животе.
Когда начали проектировать архитектуру следующего проекта, я понял, что полагаться на внимательность разработчиков на код-ревью - это тупик. Рано или поздно кто-то устанет, ошибется, и данные снова протекут.
Искал способ гарантировать изоляцию данных так, чтобы ее физически нельзя было забыть.
Почему стандартные решения не подошли?
Перебрал классическую тройку вариантов, и у каждого нашлись фатальные минусы для задачи:
1. Логическая изоляция (WHERE в коде)?
Как это работает: Тысячи строк кода, и в каждом запросе ты обязан помнить про tenant_id.
Проблема: Человеческий фактор. Это бомба замедленного действия.
2. Схема на клиента (Schema-per-tenant)
Как это работает: У каждого клиента своя схема (schema_01, schema_02...).
Проблема: Это работает, пока клиентов 100. Когда их становится 10 000, база начинает задыхаться.
Детали: Проблема даже не в миграциях, а в файловой системе. 10 000 клиентов × 50 таблиц = 500 000 файлов. Postgres (и Linux) сходят с ума от такого количества открытых дескрипторов, а VACUUM превращается в ад.
3. Отдельная БД на клиента
Как это работает: Полная физическая изоляция.
Проблема: Ценник на инфраструктуру. Держать тысячи коннектов или инстансов RDS - экономическое самоубийство для стартапа.
Тогда посмотрел в сторону PostgreSQL Row Level Security (RLS). Честно говоря, поначалу было страшно. Отдавать логику безопасности "черному ящику" внутри БД казалось рискованным. Плюс, все вокруг пугали: "RLS убьет производительность".
Но решил попробовать.
Как это выглядит в коде?
Идея проста - приложение вообще не пишет фильтры. База данных сама знает, кто делает запрос, и "на лету" подставляет нужные условия.
Вот как это было реализовано.
1. SQL-миграция: включаем "Паранойю"
Вместо сотен проверок в Go, правила пишутся один раз в миграции.
-- Таблица документов (бизнес-данные)
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
title TEXT NOT NULL,
body TEXT
);
-- Включаем RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- ВАЖНО: Force RLS
-- Без этой строки владелец таблицы (обычно это юзер, под которым ходит приложение)
-- будет видеть ВСЁ, игнорируя политики.
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
-- Политика: "Показывай только то, что принадлежит текущему тенанту"
CREATE POLICY tenant_isolation_policy ON documents
FOR ALL
USING (tenant_id = current_setting('app.current_tenant')::uuid);
2. Middleware: контекст запроса
На уровне HTTP просто достаем ID тенанта и кладем его в контекст. Тут нет магии.
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ВАЖНО ДЛЯ ПРОДАКШЕНА:
// В реальном проекте tenant_id мы достаем из claims JWT-токена (sub/tenant_id),
// который подписан и валидирован.
// Доверять заголовку "X-Tenant-ID" от клиента нельзя - это дыра в безопасности.
// Для простоты примера в статье оставлен заголовок.
tenantStr := r.Header.Get("X-Tenant-ID")
// Валидация...
if _, err := uuid.Parse(tenantStr); err != nil {
http.Error(w, "invalid tenant id", http.StatusBadRequest)
return
}
// Кладем в контекст (в проде используем кастомный тип ключа)
ctx := context.WithValue(r.Context(), "tenant_id", tenantStr)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
3. Работа с БД: где "подружились" Go и Postgres
Самый тонкий момент - передать tenant_id из Go в переменную сессии Postgres.
Использовался пулер соединений (pgxpool). Проблема в том, что если сделать SET app.current_tenant = ..., эта настройка может "прилипнуть" к соединению. Когда соединение вернется в пул, а потом достанется другому юзеру, он получит чужие права.
Решение найдено через транзакции и set_config с параметром is_local=true.
type Postgres struct {
Pool *pgxpool.Pool
}
// Обертка для всех транзакций
func (p *Postgres) RunInTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
// 1. Hard check: Если tenant_id нет, лучше упасть в панику, чем слить данные.
// Если мы просто продолжим, то current_setting('...', true) вернет NULL,
// и запрос вернет 0 строк (из-за политики), что может скрыть баг пропущенного мидлвари.
// Поэтому Panic здесь оправдан как fail-fast механизм разработки.
tenantID, ok := ctx.Value("tenant_id").(string)
if !ok || tenantID == "" {
panic("CRITICAL: DB transaction without tenant_id!")
}
// Примечание: В реальном коде проверяется, есть ли уже транзакция в ctx,
// чтобы поддержать вложенные вызовы (Savepoints).
// Для статьи код упрощен до плоской структуры.
tx, err := p.Pool.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx) // Всегда откатываем, если не было commit
// 2. Установка переменной сессии
// Третий параметр 'true' означает, что настройка живет ТОЛЬКО до конца транзакции.
// Даже если коннект вернется в пул "грязным", Postgres сбросит этот параметр.
, err = tx.Exec(ctx, "SELECT setconfig('app.current_tenant', $1, true)", tenantID)
if err != nil { return err }
// 3. Выполняется бизнес-логика
if err := fn(ctx, tx); err != nil { return err }
return tx.Commit(ctx)
}
4. Бизнес-логика: Наслаждение чистотой
Теперь код выглядит так. Никаких WHERE.
func ListDocuments(ctx context.Context, db *Postgres) ([]Document, error) {
var docs []Document
// Просто делаешь SELECT *
// Postgres сам подставит "WHERE tenant_id = ..."
err := db.RunInTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Используем библиотеку (например, pgxscan) для маппинга в структуру.
return pgxscan.Select(ctx, tx, &docs, "SELECT * FROM documents ORDER BY created_at DESC")
})
return docs, err
}
А как это тестировать? (Testing the Un-testable)
Вот тут у меня случился первый ментальный блок.
Традиционные юнит-тесты с моками (go.mock и компания) против RLS абсолютно бесполезны.
Почему?
Потому что RLS - это логика, выполняемая внутри движка базы данных, в момент планирования запроса. Вы можете замокать вызов current_setting(), но вы проверите только свой мок. Вы не проверите:
Действительно ли политика применилась?
Не протекли ли данные через индекс?
Остается один путь - честные интеграционные тесты на живой базе.
Раньше мы поднимали локальный docker-compose up, и тесты стучались туда. Это был "ад зависимого состояния". Тест А изменил данные, Тест Б упал, потому что ожидал пустоту. Разработчики начинали комментировать тесты со словами "на CI починится".
Решение, которое спасло нервы - Testcontainers.
Для экосистемы Java это стандарт де-факто уже лет 5, в Go оно пришло позже, но сейчас работает стабильно.
Суть проста: каждый запуск тестов (или даже каждый отдельный тест-кейс) поднимает чистый, стерильный Docker-контейнер Postgres, накатывает туда ваши миграции, прогоняет сценарий и убивает контейнер.
// internal/infra/db/container_test.go
func SetupTestDB(t testing.TB) string {
ctx := context.Background()
// 1. Поднимаем "тяжелый" контейнер (делается 1 раз на пакет тестов)
// Используем тот же image, что и в проде!
// Для Remote Docker (CI/CD, Kubernetes) маунт файлов не сработает.
// Используем CopyFileToContainer:
pgContainer, err := postgres.Run(ctx,
"pgvector/pgvector:pg16",
postgres.WithDatabase("ronin_test"),
postgres.WithUsername("ronin"),
postgres.WithPassword("password"),
postgres.WithCopyFileToContainer(
"./migrations/",
"/docker-entrypoint-initdb.d/",
0644,
),
)
if err != nil {
t.Fatalf("failed to start postgres: %v", err)
}
// 2. Гарантируем очистку ресурсов
t.Cleanup(func() { pgContainer.Terminate(ctx) })
// 3. Накатываем структуру БД
connStr, _ := pgContainer.ConnectionString(ctx, "sslmode=disable")
applyMigrations(t, connStr)
return connStr
}
И теперь сам тест выглядит лаконично. Никаких моков, только живая база:
func TestAlienAccess(t *testing.T) {
// 1. Получаем чистую базу (за 300мс)
dsn := SetupTestDB(t)
db := connect(dsn)
// 2. Создаем документ для Tenant A
docID := createDoc(db, "tenant-A", "Secret Plan")
// 3. Пытаемся прочитать под Tenant B
ctxB := context.WithValue(context.Background(), "tenant_id", "tenant-B")
_, err := db.GetDoc(ctxB, docID)
// 4. Проверяем, что базы для нас "не существует"
assert.ErrorIs(t, err, sql.ErrNoRows)
}
// Хелперы для тестов (createDoc, connect) опущены для краткости,
// но они должны использовать тот же пул соединений.
Благодаря этому получилось выработать минимальную стратегию тестирования RLS, без которой код не попадает в main:
1. Тест "Свой среди своих":
Создаем Tenant A. Создаем документ. Читаем под контекстом Tenant A.
Ожидание: Документ найден.
2. Тест "Чужой":
Создаем документ под Tenant A. Пробуем прочитать его под контекстом Tenant B.
Ожидание: sql.ErrNoRows. Не ошибка доступа, а именно "нет данных". Это важно, чтобы не раскрыть даже факт существования записи.
3. Тест "Аноним / Ошибка контекста":
Пробуем сделать запрос вообще без установки app.current_tenant (симуляция бага в Middleware).
Ожидание: PANIC в Go-коде (наш выбор) или ошибка SQL "value too long / null constraint". Главное - не данные всех клиентов.
4. Тест "SQL-инъекция в ID":
В TenantID передаем ' OR '1'='1.
Ожидание: UUID-парсер Go должен упасть еще до похода в базу. Если дошли до базы - RLS должен упасть на касте типов.
Только такой набор тестов дает право спать спокойно. Если тест прошел - значит, защита работает на уровне бинарного протокола базы данных, а не на уровне "кажется, мы везде добавили WHERE".
Views и Security Barrier
Третий момент - это Views (представления). Допустим, вы хотите сделать публичную "витрину" документов.
CREATE VIEW public_docs AS SELECT * FROM docs WHERE is_public = true;
Казалось бы, безопасно? Нет.
Хакер может сделать запрос: SELECT * FROM public_docs WHERE heavy_function(secret_field).
Если планировщик решит выполнить heavy_function до фильтрации is_public (а он может), ваша функция получит доступ к приватным данным.
Лечится это одной опцией: WITH (security_barrier). Она заставляет Postgres сначала отфильтровать строки внутри View, и только потом отдавать их наружу.
CREATE VIEW public_docs_view WITH (security_barrier) AS
SELECT ... FROM documents WHERE is_public = true;
Запомните: любые View поверх RLS-таблиц должны быть с security_barrier. Иначе это решето.
Честные Бенчмарки и "Боль" RLS
Я решил не верить слухам и прогнал нагрузочные тесты с разными сценариями (Docker, ~10k записей). Результаты заставили задуматься.
Сценарий |
Без RLS |
С RLS |
Оверхед |
Вывод |
Simple Select (ID lookup) |
1.2 ms |
1.3 ms |
+0.1 ms |
Неощутимо, идеально для CRUD |
JOIN (Docs + Tenants) |
1.25 ms |
1.35 ms |
+0.1 ms |
Джойны работают отлично, планировщик умница |
Vector Search (HNSW) |
3-5 ms |
5-6 ms |
~10-20% |
Терпимо, альтернатива (схемы) ест больше памяти |
GROUP BY (Count) |
0.8 ms |
1.95 ms |
x2.4 |
Postgres не может брать стату из метаданных |
ILIKE Search (No Index) |
9.0 ms |
9.1 ms |
+1% |
RLS ни в чем не виноват, SeqScan сам по себе тормоз |
ILIKE Search (GIN Index) |
0.2 ms |
1.3 ms |
x6 |
Оверхед проверки прав на каждой строке |
Transaction Pooling |
OK |
OK |
- |
Работает идеально с |
Statement Pooling |
OK |
BROKEN |
N/A |
Опасно! Контекст теряется, данные утекают. Не использовать |
Когда RLS становится проблемой
По таблице видно, что RLS - не серебряная пуля.
1. Агрегации (COUNT(*), MAX) убивают:
Обычно Postgres знает, сколько строк в таблице. С RLS он "слепнет", так как обязан проверить видимость каждой строки.
SELECT COUNT(*) с RLS на миллионе строк - это всегда Full Scan.
Решение: Денормализация (храните счетчики в отдельном поле) или используйте SECURITY DEFINER вьюхи для админских дашбордов.
Примечание: Денормализация - это не просто "добавить поле". Это триггеры на INSERT/DELETE и периодическая пересборка счетчиков. Не пытайтесь делать UPDATE tenant_stats прямо из кода приложения - получите дедлоки и рассинхрон. Пусть этим занимается база через триггеры.
2. Оверхед на быстрых индексах:
Посмотрите на кейс с GIN индексом. "Голый" поиск занимает 0.2ms. С RLS - 1.3ms. Мы платим 1.1ms чистого процессорного времени на проверку прав найденных строк.
Для Highload-поиска (тысячи RPS) RLS может стать узким горлышком.
3. Сложные политики = Смерть:
Политика с саб-запросом (tenant_id IN (SELECT...)) выполнится для каждой строки. С RLS правило одно: политика должна быть тупой (tenant_id = $1).
Единственный путь для Vector Search (AI)
Где RLS может быть безальтернативным - это работа с векторами (pgvector).
Если делать AI-поиск по базе:
В подходе Schemas: нужно строить HNSW-индекс для каждой схемы. Это сотни гигабайт RAM
В подходе RLS: строится один гигантский HNSW-индекс на поле embedding
Postgres эффективно использует индекс, а RLS накладывает фильтр поверх. Это позволяет масштабировать векторный поиск на тысячи клиентов без раздувания памяти.
Важный нюанс:
Убедитесь, что используете pgvector 0.8.0+.
В старых версиях RLS мог "скрывать" результаты (Index Scan находил топ-K ближайших вообще*, а потом RLS отфильтровывал чужие, оставляя вам 0 результатов).
В 0.8.0+ появился итеративный скан, который продолжает искать в индексе, пока не наберет нужное количество (LIMIT) видимых* пользователю записей.
Грабли (Куда без них?)
Не всё было гладко. Вот о чем стоит знать заранее:
Грабли №1: Вьюхи - это дыра по умолчанию
Это классика, на которой подрываются почти все.
Вы включили RLS. Вы проверили SELECT * FROM table - работает.
Вы создали View для удобства: CREATE VIEW docs_v AS SELECT * FROM documents.
Вы делаете селект из вьюхи... и видите все данные всех клиентов.
Почему?
В PostgreSQL (до 15 версии) вьюхи по умолчанию выполняются с правами Владельца (View Owner), а не текущего юзера.
Обычно владелец вьюхи - это тот же юзер, что создавал таблицы (migrator/admin). А владелец таблицы по умолчанию игнорирует RLS (если не включен FORCE ROW LEVEL SECURITY, о котором мы не зря говорили в начале).
Решения:
1. Postgres 15+ (Правильный путь):
Используйте security_invoker = true.
CREATE VIEW docs_v WITH (security_invoker = true) AS SELECT ...
Это заставляет вьюху выполняться от имени того, кто делает запрос (Invoker), а не владельца (Definer). RLS применяется автоматически. Это Gold Standard для современных версий.
2. Для старых версий:
Всегда включайте ALTER TABLE ... FORCE ROW LEVEL SECURITY. Это заставит даже владельца подчиняться правилам.
3. SECURITY DEFINER (Осознанный риск):
Иногда вам нужно, чтобы вьюха игнорировала RLS (например, чтобы показать юзеру "Всего документов в системе: 100500", даже тех, к которым у него нет доступа).
Тогда вы создаете функцию или вьюху с SECURITY DEFINER. Но тут вы ходите по лезвию: вы обязаны вручную написать WHERE tenant_id = ... внутри, иначе сольете базу.
Грабли №2: Невидимая стена производительности (Leakproof)
Я долго не мог понять, почему простой поиск ILIKE кладет базу на лопатки, хотя индексы есть.
Симптомы:
SELECT ... WHERE title ILIKE '%foo%' без RLS работает 50мс.
Тот же запрос с RLS работает 5 секунд.
Причина:
Postgres боится выполнять ваши функции ДО проверки прав RLS. А вдруг функция search(text) отправляет этот текст хакерам по HTTP? Или кидает ошибку "Текст найден", выдавая наличие секретного документа?
Поэтому, если функция/оператор не помечены как LEAKPROOF (непротекающие), планировщик может (но не обязан) перестраховаться и сначала проверить tenant_id = ... для ВСЕХ строк таблицы (SeqScan!), и только потом применит фильтр.
Это не всегда происходит (в PG 12+ оптимизатор умный), но LEAKPROOF - это ваша гарантия стабильности плана выполнения, чтобы производительность не зависела от фазы луны и актуальности статистики.
Пруфы (EXPLAIN ANALYZE):
До (Seq Scan - 500ms):
Seq Scan on documents (cost=0.00..3452.00 rows=1)
Filter: ((tenant_id = '...'::uuid) AND (title ~~* '%foo%'::text))
Видите? Один общий фильтр. Индекс проигнорирован.
После (Index Scan - 1.3ms):
Index Scan using documents_title_trgm_idx on documents
Index Cond: (title ~~* '%foo%'::text)
Filter: (tenant_id = '...'::uuid)
Здесь база сначала нашла строки по индексу (Index Cond), а потом отфильтровала их по RLS (Filter). Победа.
Решение:
1. Использовать расширения с Leakproof-операторами (например, pg_trgm для GIN индексов часто безопасен).
2. Обернуть поиск в LEAKPROOF функцию:
CREATE OR REPLACE FUNCTION safe_title_search(term TEXT) RETURNS SETOF doc
LANGUAGE sql STABLE LEAKPROOF AS $$
SELECT * FROM doc WHERE title ILIKE '%' || term || '%'
$$;
Это говорит базе: "Мамой клянусь, эта функция безопасна". И вуаля - снова Index Scan и 50мс.
Грабли №3: PgBouncer и режим Transaction Pooling
Многие боятся RLS, потому что думают, что он несовместим с пулерами соединений в режиме транзакций. А ведь именно этот режим нужен для хайлоада.
Проблема:
Если сделать обычный SET app.current_tenant = '...', переменная привязывается к физическому соединению. Когда транзакция завершается, PgBouncer возвращает "грязное" соединение в пул. Следующий запрос (от другого клиента!) может получить это соединение и увидеть чужие данные.
Решение:
Третий аргумент в set_config.
set_config('app.current_tenant', $1, true) - флаг is_local=true говорит Постгресу: "Эта переменная живет только до конца текущей транзакции".
Даже если PgBouncer вернет коннект в пул, Postgres гарантированно почистит контекст при COMMIT или ROLLBACK. Это делает RLS абсолютно безопасным для Transaction Pooling.
КРИТИЧНО: Statement Pooling режим (pool_mode = statement) абсолютно несовместим с RLS и set_config. В этом режиме транзакция может быть разорвана между разными соединениями, и контекст будет потерян или перепутан. Используйте только Transaction Pooling.
Грабли №4: Суперюзер и фоновые задачи
Важно помнить: RLS не применяется к суперпользователю (postgres) и владельцу таблицы (если не включен FORCE).
Но есть крон-джобы и админка, которым нужно видеть данные всех тенантов. Делать SET app.current_tenant в цикле для каждого тенанта - глупо.
Решение:
Была создана отдельная роль в БД для сервисных задач с атрибутом BYPASSRLS:
ALTER ROLE background_worker WITH BYPASSRLS;
Получилось чистое разделение: app_user (клиентский API) жестко ограничен, а admin_user (воркеры) видит всё.
Грабли №5: Сложные политики
Если захочется сделать логику "Менеджер видит 5 клиентов", USING станет сложным и может начать кушать CPU. Главное - держать политики максимально простыми.
Как внедрить RLS на живом проекте (The 3-Phase Plan)
Самый частый вопрос, который мне задают: "Это все здорово для нового проекта, но у меня монолит на 500 таблиц и 100GB данных. Если я включу RLS, мы встанем колом?".
Внедрение RLS в легаси - это операция на открытом сердце. Одно неверное движение с ALTER TABLE, и прод лежит.
Но это возможно. Мы выработали безопасный 3-фазный план миграции, который позволяет внедрять RLS без даунтайма.
Фаза 1: Preparation (Permissive Mode)
Цель: Подготовить структуру БД, не ломая логику приложения.
1. Добавляем колонки: Добавляем tenant_id во все таблицы.
Важно: Не ставьте сразу NOT NULL, если таблица огромная. Это вызовет долгий лок и перестроение heap. Лучше добавить NULL, начать писать туда данные бэкендом, а потом в фоне заполнить старые записи.
2. Включаем RLS "для галочки": Создаем политики, которые разрешают все.
-- Включаем механизм, но не блокируем доступ
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Политика "Добрый вахтер": Пускать всех
CREATE POLICY migration_phase_1 ON orders
FOR ALL
USING (true)
WITH CHECK (true);
В этой фазе приложение работает как раньше. БД никак не ограничивает доступ. Но мы уже можем проверить, что ENABLE RLS не убил перформанс планировщика.
Фаза 2: Transition (Hybrid Mode)
Цель: Научить бэкенд работать с контекстом, сохраняя обратную совместимость.
Мы меняем Middleware приложения, чтобы он начал слать set_config('app.current_tenant', ...).
Но мы не можем гарантировать, что весь код уже обновлен. Где-то в глубине легаси может быть cron-скрипт или админка, которые работают без контекста.
Меняем политику на Гибридную:
DROP POLICY migration_phase_1 ON orders;
CREATE POLICY migration_phase_2 ON orders
FOR ALL
USING (
-- Вариант А: Есть контекст -> фильтруем жестко
(current_setting('app.current_tenant', true) IS NOT NULL
AND tenant_id = current_setting('app.current_tenant')::uuid)
OR
-- Вариант Б: Нет контекста (старый код) -> пускаем всех (или пишем в лог)
(current_setting('app.current_tenant', true) IS NULL)
);
В этой фазе мы мониторим логи. Можно настроить аудит, чтобы видеть, какие запросы приходят без app.current_tenant, и планомерно их фиксить.
Предупреждение: Эта фаза логически опасна! Конструкция OR ... IS NULL означает, что если вы забудете передать контекст, запрос вернет всю базу, а не ошибку. Без строгого логирования (через RAISE WARNING внутри политики или pgAudit) этот режим - дыра в безопасности. Используйте его только кратковременно и под наблюдением.
Система всё еще уязвима, но мы уже тестируем механику изоляции для нового кода.
Фаза 3: Enforcement (Strict Mode)
Цель: Полная блокировка доступа без тенанта.
Когда мы уверены, что 100% легитимных запросов приходят с контекстом, мы захлопываем ловушку.
DROP POLICY migration_phase_2 ON orders;
-- Политика "Злой вахтер"
CREATE POLICY tenant_isolation_policy ON orders
FOR ALL
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Финальный штрих: Запрещаем владельцам обходить правила
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
Если теперь какой-то забытый скрипт попробует прочитать данные без RunInTx, он получит пустую выборку (или ошибку, если настроить panics в коде). Поздравляю, вы мигрировали без единой минуты простоя.
Итог
Переход на RLS был не про "успешный успех", а про паранойю. Хочется спать спокойно.
В итоге получена система, где безопасность гарантируется на уровне инфраструктуры, а не на уровне внимательности джуниора. Код стал чище, тестов на авторизацию стало меньше, а производительность просела в пределах погрешности.
Если пишете мультитенантный бэкенд на Postgres - попробуйте RLS. Это не магия, это просто хороший инженерный инструмент, который незаслуженно обходят стороной.
В следующей статье расскажу, почему решил взрывать (panic) приложение, если оно пытается работать без тенанта.
Комментарии (26)

vesper-bot
21.01.2026 10:28А если в таблице сделать индекс по tenant_id, разве планировщик при такой политике RLS не станет его использовать при поиске по LIKE?

Ronin_GO Автор
21.01.2026 10:28Как упоминал в разделе "Грабли №2", планировщик Postgres - параноик. Даже если есть индекс, без метки LEAKPROOF на операторе поиска он может выбрать Seq Scan, чтобы не "протечь" данными через индекс до проверки прав. Поэтому индекс на tenant_id - это база, но для LIKE/ILIKE часто нужен составной индекс или обертка в LEAKPROOF-функцию, чтобы заставить Postgres поверить, что поиск безопасен

acsent1
21.01.2026 10:28По идее tenant_id должен быть в любом индексе

Ronin_GO Автор
21.01.2026 10:28Вы имеете в виду обязательные составные индексы (tenant_id, column) для стабильного Index Scan? Или какой-то более хитрый кейс? На самом деле, тема индексов - это только вершина айсберга. Когда данных становится слишком много, приходится смотреть в сторону партиционирования по tenant_id, но это тянет на отдельный большой разбор (который, кстати, у меня почти уже готов)

RekGRpth
21.01.2026 10:28вроде в цитусе (ставится как расширение) есть мультитенантность

Ronin_GO Автор
21.01.2026 10:28Citus - это отличное решение для горизонтального масштабирования, когда данные измеряются терабайтами и шардинг становится неизбежен. Но это инфраструктурное усложнение, которое оправдано только при высоких нагрузках. В своей статье делал упор на логическую изоляцию в рамках стандартного Postgres. RLS позволяет гибко настраивать права доступа (например, разные роли внутри одного тенанта) без раздувания инфраструктуры и лишних затрат на поддержку кластера. Для большинства SaaS-задач на старте и этапе роста RLS выглядит, как более прагматичный выбор

michael_v89
21.01.2026 10:28Как теперь вывести данные по разным tenant_id в админке для менеджера?

Ronin_GO Автор
21.01.2026 10:28Для админки есть самый простой вариант. Используйте роль с BYPASSRLS, про которую упоминал в разделе "Грабли №4". Так как роль игнорирует политики, вы получаете доступ ко всем данным и фильтруете их явно

michael_v89
21.01.2026 10:28Столько нюансов по настройке, которые легко пропустить. Проще было бы сделать обертку в коде, которая сама добавляет условие по tenant_id для заданных таблиц в from. Для этого лучше использовать ORM и QueryBuilder, а не сырые SQL-запросы. Или хотя бы ищет это условие в тексте запроса и бросает исключение если его нет.

Ronin_GO Автор
21.01.2026 10:28Сырые SQL-запросы будут неизбежно появляться по мере роста проекта, а это, опять же, человеческий фактор, от которого пытаемся уйти

gsl23
21.01.2026 10:283. Отдельная БД на клиента
Не знаю конечно ваши объемы , но если брать общий случай , то RLS, там где можно без него - это 100% ошибка проектирования.
Если у вас реально логически клиенты никак не связаны, и могут быть помещены в отдельную бд , то так и надо делать, это гораздо лучше чем RLS. Keep it simple , bro )
Производительность , возможности масштабирования и настройки - все в 100 раз лучше.
Легкие БД - в рамках одного инстанса , стали тяжелей - можно размазать на сколько нужно инстансов, на какую нужно инфру. Возможности масштабирования огромны.
Ronin_GO Автор
21.01.2026 10:28Ваш подход идеально вписывается, когда есть клиенты с хорошими чеками, где затраты на клиента не превышают доходы от него. Это золотой стандарт индустрии, тут спорить никто не станет. А если у вас массовый SaaS с тысячами мелких клиентов, то проблемы изолированного подхода накатываются, как снежный ком. Раскатка миграций на 10 000 баз, управление пулами соединений и сбор сквозной аналитики превращаются в DevOps-ад, который стоит дороже, чем сэкономленные ресурсы. Считаю, что все зависит от бизнес-требований

gsl23
21.01.2026 10:28все зависит от бизнес-требований
Логично, может это и оправданно в вашем случае, поэтому и про объемы упомянул.
Но в любом случае надо держать в голове , ваш подход - это пороховая бочка, которая к каждому(!) обращению добавляет index range scan по сути, и рано или поздно, это убъет производительность. Притом какой-нибудь самый важный и есс-но "жирный" по данным клиент - он будет ждать дольше всех.
Ronin_GO Автор
21.01.2026 10:28Согласен, этот риск нужно держать в голове. RLS - это не "серебряная пуля". Но архитектура не статична. RLS позволяет нам эффективно жить на одной машине на старте и в фазе активного роста. А когда упрешься в этот потолок (или появится тот самый "жирный" клиент), то ваше решение подойдет идеально. У любого подхода есть свои ограничения

Gromilo
21.01.2026 10:281. Логическая изоляция (WHERE в коде)?
Делал почти так же как у тебя. tenant_id хранился в таблице, а все запросы шли через вьюхи с таким же фильтром
tenant_id = current_setting('app.current_tenant')::uuid.Все индексы были вида (tenant_id, id), а внешние ключи обязательно содержали tenant_id.

OlegIct
21.01.2026 10:28если на таблицы в отлельной схеме создать представления с select * from schem.tab where tenant_id = current_setting('app.current_tenant')::uuid, не давать привилегии на таблицы, дать на представления. Есть ли у RLS преимущества по сравнению с представлениями?

Ronin_GO Автор
21.01.2026 10:28Представления работают, но требуют ручного обновления при изменении схемы таблиц, усложняют запись (триггеры) и оставляют риск утечки через забытую View. С RLS таких проблем не будет

OlegIct
21.01.2026 10:28цель - разобраться. Вы отлично описали RLS, статья хорошая. Вопросы по нужности использования самого RLS на практике. Может быть у view есть недостатки, о которых я не знаю и вы или кто-то подскажет. Возражения (не чтобы возразить, а чтобы выяснить - может я что-то не так понял):
требуют ручного обновления при изменении схемы таблиц
при изменении структуры таблицы менять view не надо, в нём “select *”.
усложняют запись (триггеры)
триггеры создавать не нужно - view простое.
оставляют риск утечки через забытую View
Если забыть создать view - доступа не будет, так как на схему хранения таблиц и на сами таблицы доступ не даётся. Если забыть удалить view - тоже не будет, так как если в таблице нет столбца tenant_id, то доступа и не будет. А если столбец в новой таблице есть, то значит строки предназначены для просмотра и чужих строк view не выдаст.
С RLS наоборот - создал таблицу, забыл добавить политику и всё - доступ есть ко всем строкам.
Postgres (и Linux) сходят с ума от такого количества открытых дескрипторов, а
VACUUMпревращается в аддля точности: по умолчанию в Postgres max_files_per_processes=1000, в linux max_open_files=1024. Если файлов окрыто 1000, а процессу нужен доступ еще к одному файлу, процесс закрывает открытый файл. Это создает задержку и даже увеличивают лимиты, но при 50 таблицах не слишком критичную, так как таблицы небольшие, то fsync по файлам выполняется быстро.

Ronin_GO Автор
21.01.2026 10:28Если забыть создать view - доступа не будет, так как на схему хранения таблиц и на сами таблицы доступ не даётся. Если забыть удалить view - тоже не будет, так как если в таблице нет столбца tenant_id, то доступа и не будет. А если столбец в новой таблице есть, то значит строки предназначены для просмотра и чужих строк view не выдаст.
С RLS наоборот - создал таблицу, забыл добавить политику и всё - доступ есть ко всем строкам.
Соглашусь с вашим тезисом, схема "нет View - нет доступа" действительно надежна. Тут я, пожалуй, погорячился, когда назвал это прямой утечкой. Основной риск здесь скорее в человеческом факторе при масштабировании. Если таблиц станет 100+, кто-то из разработчиков может допустить ошибку. RLS в этом плане кажется более монолитным решением. Но вы правы, убрать полностью человеческий фактор невозможно. Все же, проблема человеческого фактора с RLS легко решается одной проверкой в CI. А вот проверить View на корректность будет сложнее
триггеры создавать не нужно - view простое.
По поводу записи через триггеры, то да, для простых View они не нужны, но если логика усложнится, поддержка триггеров может добавить головной боли.
при изменении структуры таблицы менять view не надо, в нём “select *”.
По поводу SELECT *. Если правильно помню, звездочка раскрывается в список колонок именно в момент создания представления. То есть, если мы добавим колонку в таблицу позже, во View она автоматически не появится, и его придется пересоздавать. Но тут лучше сверится с докой Postgres
для точности: по умолчанию в Postgres max_files_per_processes=1000, в linux max_open_files=1024. Если файлов окрыто 1000, а процессу нужен доступ еще к одному файлу, процесс закрывает открытый файл. Это создает задержку и даже увеличивают лимиты, но при 50 таблицах не слишком критичную, так как таблицы небольшие, то fsync по файлам выполняется быстро
Кажется, вы упустили множитель в моем примере. Речь не о 50 таблицах, а о 500 000 (10к клиентов × 50 таблиц). Если всего 50 таблиц, то проблем не будет

OlegIct
21.01.2026 10:28По поводу SELECT *. Если правильно помню, звездочка раскрывается в список колонок именно в момент создания представления. То есть, если мы добавим колонку в таблицу позже, во View она автоматически не появится, и его придется пересоздавать. Но тут лучше сверится с докой Postgres
да, точно! это неудобство, хотя может это правильно с точки зрения безопасности или ещё по какой-то причине
Кажется, вы упустили множитель в моем примере. Речь не о 50 таблицах, а о 500 000 (10к клиентов × 50 таблиц). Если всего 50 таблиц, то проблем не будет
каждый клиент работает с 50 таблицами, то есть запрос одновременно обращается максимум к 50 таблицам. Если используется пулер, то для запроса другого прользователя серверный процесс часть файлов закроет, часть откроет. Проблема была бы, если одному запросу понадобилось бы больше 1000 файлов. Но понятно, что огромное число файлов в кластере баз данных хуже и решение с RLS в этом имеет плюсы, если размер таблиц получится не слишком большим.
То есть, в целом, можно рассматривать использование и RLS и view под конкретную задачу и выбрать то, что больше подходит

Ronin_GO Автор
21.01.2026 10:28каждый клиент работает с 50 таблицами, то есть запрос одновременно обращается максимум к 50 таблицам. Если используется пулер, то для запроса другого прользователя серверный процесс часть файлов закроет, часть откроет. Проблема была бы, если одному запросу понадобилось бы больше 1000 файлов. Но понятно, что огромное число файлов в кластере баз данных хуже и решение с RLS в этом имеет плюсы, если размер таблиц получится не слишком большим.
Даже если допустить такой исход, это все равно путь к деградации. Задержки неизбежны. В каких-то случаях это допустимо, в каких-то - нет
То есть, в целом, можно рассматривать использование и RLS и view под конкретную задачу и выбрать то, что больше подходит
Согласен, нет идеального решения для всех. Все зависит от задачи
Plesser
Спасибо!
Ronin_GO Автор
Рад, что материал оказался полезен. Если будете внедрять RLS у себя, делитесь фидбеком. Будет интересно обсудить, как решение покажет себя в ваших условиях
Plesser
Нам только предстоит миграция АБС с Oracle на Postgres, и разработчик АБС уже предупреждает, что будет просадка производительности на 30%. Поэтому сейчас для нас любая информация об особенностях работы postgre цена :)
Ronin_GO Автор
Postgres RLS, про который написл, - это решение скорее для динамичных SaaS-систем, где нужно масштабироваться на тысячи мелких клиентов без раздувания количества объектов в БД. В вашем случае на Postgres основная сложность будет даже не в безопасности, а в том, как оптимизатор справится со сложными планами запросов после Oracle. Просадка в 30% - это еще оптимистичный прогноз от разработчиков. Часто в таких кейсах приходится пересматривать индексы и структуру запросов с нуля. Будет интересно узнать, на какой архитектуре вы в итоге остановитесь. Удачи с миграцией!
Plesser
Да, я понимаю это. Но как показывает мой опыт знания всех возможностей системы часто позволяет найти неочевидное но эффективное решение.