← Все статьи
24 июня 20265 минПрактикаsupabase rls · row level security · multi tenant saas · postgres rls · supabase security · multi-tenant

Multi-tenant SaaS на Supabase RLS: без дыр и боли

Row Level Security в Supabase звучит просто, пока не упрёшься в рекурсию политик, тормоза на каждой строке и тихие утечки между клиентами. Разбираю на реальном коде M.Flow.

Степан Милахин

Когда строишь SaaS, где каждый клиент это отдельный бизнес со своими данными, главный страх простой: чтобы один клиент случайно не увидел данные другого. В M.Flow это критично, там лиды, переписки, рекламные кабинеты разных бизнесов в одной базе.

Можно фильтровать доступ на бэкенде: добавлять where tenant_id = ? в каждый запрос. Это работает ровно до первого места, где ты забыл этот where. Один пропущенный фильтр в одном эндпоинте, и клиент видит чужие лиды. Защита на честном слове разработчика это не защита.

Supabase даёт способ лучше: Row Level Security. Изоляция живёт в самой базе. Даже если бэкенд накосячил и не добавил фильтр, Postgres сам не отдаст чужие строки. Звучит как серебряная пуля, и в целом так и есть. Но между «включил RLS» и «оно реально изолирует и не тормозит» я собрал несколько грабель. Разберу по порядку.

Базовая идея за две минуты

RLS это политики на таблице, которые Postgres подмешивает в каждый запрос. Включаешь enable row level security на таблице, и пишешь политику: какие строки этому пользователю видны.

В multi-tenant модели нужна связка: пользователь принадлежит одному или нескольким тенантам, и видит только строки своих тенантов. У меня это таблица членства:

mf_tenant_members (tenant_id uuid, user_id uuid, role text)

Пользователь видит строку, если её tenant_id есть среди его тенантов. Наивная политика выглядела бы так:

create policy mf_leads_sel on mf_leads
  for select to authenticated
  using (
    tenant_id in (
      select tenant_id from mf_tenant_members
      where user_id = auth.uid()
    )
  );

И вот тут начинается первая граблина.

Грабля 1: рекурсия политик

Как только ты включишь RLS на таблице членства mf_tenant_members (а ты должен, это тоже чувствительные данные), политика выше начинает ссылаться на таблицу, у которой свои RLS-политики. Postgres, проверяя доступ к mf_leads, лезет в mf_tenant_members, а там снова проверка доступа, которая может снова лезть куда-то. Получается рекурсия, и запрос либо падает, либо ведёт себя непредсказуемо.

Лечится это функцией с security definer. Такая функция выполняется с правами своего создателя и обходит RLS внутри себя. Ты выносишь логику «какие тенанты у текущего пользователя» в одну функцию:

create or replace function mf_current_tenant_ids()
returns setof uuid
language sql
stable
security definer
set search_path = public
as $$
  select tenant_id from mf_tenant_members
  where user_id = auth.uid()
$$;

И все политики теперь зовут её, а не лезут в таблицу напрямую:

create policy mf_leads_sel on mf_leads
  for select to authenticated
  using (tenant_id in (select mf_current_tenant_ids()));

Рекурсии нет, потому что внутри функции RLS не действует. Обрати внимание на set search_path = public в определении функции. Без него security definer функция это потенциальная дыра: атакующий может подменить search_path и подсунуть свою таблицу. Это не паранойя, это стандартная рекомендация, я зашил её сразу.

То же самое с проверкой роли админа, у меня это отдельная security definer функция is_admin(), которая читает роль из таблицы пользователей в обход RLS. Любая проверка доступа, которая читает таблицу с RLS, должна идти через такую функцию.

Грабля 2: политика зовётся на каждую строку

Эта меня укусила не сразу. Сначала всё работает, потом база растёт, и запросы начинают тормозить на ровном месте.

Причина: auth.uid() и твоя функция тенантов по умолчанию вычисляются для каждой строки, которую Postgres проверяет. Если в таблице сто тысяч строк, функция дёргается сто тысяч раз за один запрос. На пустой базе незаметно, на живой это становится узким местом.

Фикс механический, но его легко не знать. Оборачиваешь вызов в подзапрос (select ...):

using (tenant_id in (select mf_current_tenant_ids()))

вместо

using (tenant_id in (mf_current_tenant_ids()))

Разница в скобках с select. Так Postgres понимает, что результат можно вычислить один раз на весь запрос (это называется initplan), а не дёргать на каждую строку. То же касается auth.uid(): обернуть в (select auth.uid()).

Я в какой-то момент прошёлся по всем политикам разом и переписал их в эту форму. Это была механическая правка без изменения логики, но она задела несколько десятков политик на двух десятках таблиц. Семантика та же, скорость другая. Если строишь multi-tenant на RLS, закладывай эту обёртку сразу, не придётся потом переделывать.

Грабля 3: using защищает чтение, with check защищает запись

Это место, где можно создать тихую дыру и не заметить.

Политика с using фильтрует, какие строки видны на чтение. Но using не мешает вставить строку с чужим tenant_id. Если у тебя политика for all только с using, пользователь технически может вставить или обновить запись, проставив туда чужой тенант, и эта запись уйдёт другому клиенту.

Поэтому на запись нужен отдельный with check, который проверяет, что записываемый tenant_id принадлежит пользователю:

create policy mf_leads_all on mf_leads
  for all to authenticated
  using (tenant_id in (select mf_current_tenant_ids()))
  with check (tenant_id in (select mf_current_tenant_ids()));

using отвечает на вопрос «какие строки я могу трогать», with check на вопрос «какие строки мне можно записать». В multi-tenant нужны оба, иначе изоляция дырявая на запись.

Грабля 4: забыл одну таблицу, и всё насмарку

RLS включается на каждой таблице отдельно. Добавил новую таблицу, забыл enable row level security, и она открыта всем по умолчанию настроек доступа. Одна забытая таблица сводит на нет всю остальную дисциплину.

Я для этого не вешаю политики руками по одной, а прогоняю циклом по списку всех tenant-scoped таблиц:

do $$
declare t text;
begin
  for t in select unnest(array[
    'mf_contacts','mf_leads','mf_messages',
    'mf_oauth_connections','mf_ad_accounts',
    'mf_campaigns','mf_campaign_metrics'
  ])
  loop
    execute format(
      'create policy %I on %I for select to authenticated
       using (tenant_id in (select mf_current_tenant_ids()))',
      t || '_sel', t);
    execute format(
      'create policy %I on %I for all to authenticated
       using (tenant_id in (select mf_current_tenant_ids()))
       with check (tenant_id in (select mf_current_tenant_ids()))',
      t || '_all', t);
  end loop;
end $$;

Один источник правды: список таблиц, которые должны быть изолированы по тенанту. Добавляешь таблицу, дописываешь в список, прогоняешь. Меньше шансов, что какая-то останется голой. Плюс полезно периодически прогонять Supabase advisor, он подсвечивает таблицы с выключенным RLS и политики без обёртки в подзапрос.

Грабля 5: service role в обход всего

Это не баг RLS, а то, о чём легко забыть. Service-role ключ обходит RLS полностью. Это правильно для серверных задач: cron, синхронизация рекламы, фоновые джобы. Но это значит, что любой код, работающий под service-role, сам отвечает за изоляцию. RLS его не подстрахует.

Правило простое: service-role только там, где он реально нужен и где код сам аккуратно скоупит данные. Везде, где работает обычный авторизованный пользователь, идём через anon-ключ с RLS. Я держу эти два мира раздельно и не смешиваю клиенты в коде: серверный admin-клиент отдельно, пользовательский отдельно.

Так что в итоге

Если коротко, чек-лист для multi-tenant на Supabase RLS получился такой.

Выноси проверку доступа в security definer функцию с set search_path, чтобы не словить рекурсию. Оборачивай вызовы в (select ...), чтобы они считались один раз на запрос, а не на каждую строку. На запись всегда добавляй with check, а не только using. Включай RLS на всех tenant-таблицах разом через цикл, держи список таблиц как единый источник правды. И помни, что service-role обходит RLS, поэтому серверный код изолирует данные сам.

RLS это хорошая защита, но не магия. Она работает ровно настолько, насколько аккуратно написаны политики. Зато когда написаны правильно, можно спать спокойно: даже если бэкенд где-то забудет фильтр, база чужое не отдаст. Ради этого и стоит с ней повозиться.

Если строишь свой multi-tenant и упёрся в RLS или в сквозную изоляцию данных, напиши, разберём конкретный случай.

Похожие статьи
Контакты

Расскажите о задаче — посмотрим, по пути ли нам

Короткий бриф или просто «хочу обсудить». Первый созвон — бесплатно и ни к чему не обязывает: честно скажем, возьмёмся ли.