Платформа · Блог · Архитектура
Архитектура24.07.2026 · 9 мин чтения

Мультитенантность на Postgres: как изолировать данные агентств через RLS

Когда одна база обслуживает сотни агентств, вопрос изоляции данных перестаёт быть архитектурным упражнением. Одна забытая проверка в одном запросе — и клиент видит проекты конкурента.

Три способа разделить арендаторов

ПодходИзоляцияЦена
База на арендатораПолнаяМиграции × N, дорогое обслуживание
Схема на арендатораВысокаяТысячи схем ломают инструментарий
Общие таблицы + RLSВысокая при верной настройкеДисциплина в политиках

Для SaaS с быстрым выпуском изменений третий вариант выигрывает: одна миграция применяется ко всем, а изоляцию обеспечивает сама СУБД, а не дисциплина разработчика.

Базовая конструкция

Каждая таблица с данными арендатора несёт agency_id. Ключевая функция — определение агентства текущего пользователя. Её объявляют stable, чтобы планировщик вычислял её один раз на запрос, а не на каждую строку:

create or replace function current_agency() returns uuid
language sql stable security definer set search_path = public as $$
  select agency_id from profiles where id = auth.uid()
$$;

alter table projects enable row level security;

create policy tenant_read on projects
  for select using (agency_id = current_agency());

Три вещи, которые ломают изоляцию

1. Таблица без включённого RLS

Забыли enable row level security — таблица открыта всем. Это не гипотетический риск, а самая частая реальная утечка. Лечится не внимательностью, а проверкой в CI: находим все таблицы схемы с relrowsecurity = false и роняем сборку.

2. Политика есть только на чтение

for select закрывает чтение, но insert, update и delete остаются без ограничений. Для записи важен with check — иначе пользователь вставит строку с чужим agency_id.

create policy tenant_write on projects
  for all
  using      (agency_id = current_agency())
  with check (agency_id = current_agency());

3. Функции с security definer

Такая функция выполняется с правами владельца и обходит RLS. Это нужно — например, чтобы отдать анониму несколько публичных полей настроек, — но каждая такая функция обязана сама фильтровать данные и возвращать только белый список полей. Обязательно указывайте set search_path, иначе вызывающий подсунет свою схему.

Проверка, которая должна быть в CI

Пользователь агентства A под своим токеном не получает ни одной строки агентства B — по каждой таблице. Это единственный тест, который реально доказывает изоляцию.

Второй уровень: доступ к конкретным проектам

Изоляции по агентству мало. Внутри агентства монтажёр не должен видеть финансы, а фрилансер — все проекты.

Как запрос проходит через два фильтраизоляция
Запрос приложенияselect * from projectsУровень 1 · RLS по агентствуagency_id = current_agency()Уровень 2 · зона доступаучастник проекта или владелецСтроки, которые вернулисьтолько проекты вашего агентства
Оба уровня работают в базе, а не в коде приложения. Даже если запрос ушёл напрямую в API, минуя интерфейс, он вернёт только разрешённые строки.
Неочевидная ловушка на клиенте

Если интерфейс строит список видимых проектов из данных, которые подгружаются параллельно другим запросом, возникает гонка: аналитика считается до того, как приехал состав участников. Зона доступа должна строиться в том же запросе, который считает данные.

Производительность

Условия политик попадают в каждый запрос, поэтому индексы должны их поддерживать.

  • Составные индексы, начинающиеся с agency_id: (agency_id, created_at desc), (agency_id, status).
  • Функции в политиках — только stable, иначе вызов на каждую строку.
  • Проверяйте explain analyze от имени обычной роли: у суперпользователя RLS не применяется и план будет другим.

Пагинация: ограничение, о котором забывают

PostgREST и подобные слои по умолчанию отдают ограниченное число строк — как правило, тысячу. Аналитика по журналу за полгода легко превышает лимит, и метрика молча считается по обрезанным данным. Ошибки нет, цифра есть, цифра неверная.

Забор должен идти постранично через Range, с защитой от бесконечного цикла: если очередная страница начинается с той же строки, что предыдущая, значит прокси срезал заголовок — цикл нужно прервать.

Как это устроено в DETROYD

Платформа построена на Postgres с RLS: все данные несут agency_id, доступ ограничен политиками на уровне базы, внутри агентства действует второй уровень — зона доступа по участию в проектах. Публичные данные отдаются анониму через отдельную функцию с белым списком полей. Аналитические выборки забираются постранично, чтобы метрики не считались по обрезанным данным.

Так это и работает в DETROYD

Платформа управления креативным агентством: проекты и этапы, дедлайны, циклы, финансы, KPI и клиентский кабинет. Демо открывается без регистрации.