Три способа разделить арендаторов
| Подход | Изоляция | Цена |
|---|---|---|
| База на арендатора | Полная | Миграции × 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, иначе вызывающий подсунет свою схему.
Пользователь агентства A под своим токеном не получает ни одной строки агентства B — по каждой таблице. Это единственный тест, который реально доказывает изоляцию.
Второй уровень: доступ к конкретным проектам
Изоляции по агентству мало. Внутри агентства монтажёр не должен видеть финансы, а фрилансер — все проекты.
Если интерфейс строит список видимых проектов из данных, которые подгружаются параллельно другим запросом, возникает гонка: аналитика считается до того, как приехал состав участников. Зона доступа должна строиться в том же запросе, который считает данные.
Производительность
Условия политик попадают в каждый запрос, поэтому индексы должны их поддерживать.
- Составные индексы, начинающиеся с
agency_id:(agency_id, created_at desc),(agency_id, status). - Функции в политиках — только
stable, иначе вызов на каждую строку. - Проверяйте
explain analyzeот имени обычной роли: у суперпользователя RLS не применяется и план будет другим.
Пагинация: ограничение, о котором забывают
PostgREST и подобные слои по умолчанию отдают ограниченное число строк — как правило, тысячу. Аналитика по журналу за полгода легко превышает лимит, и метрика молча считается по обрезанным данным. Ошибки нет, цифра есть, цифра неверная.
Забор должен идти постранично через Range, с защитой от бесконечного цикла: если очередная страница начинается с той же строки, что предыдущая, значит прокси срезал заголовок — цикл нужно прервать.
Как это устроено в DETROYD
Платформа построена на Postgres с RLS: все данные несут agency_id, доступ ограничен политиками на уровне базы, внутри агентства действует второй уровень — зона доступа по участию в проектах. Публичные данные отдаются анониму через отдельную функцию с белым списком полей. Аналитические выборки забираются постранично, чтобы метрики не считались по обрезанным данным.