Row Level Security в Supabase: как на самом деле закрыть таблицы
Как устроены политики RLS, где в них чаще всего ошибаются и как проверить, что чужие строки действительно недоступны.
В Supabase браузер ходит в базу почти напрямую. Между ним и твоими таблицами нет бэкенда, который проверил бы права, — есть только Row Level Security. Поэтому RLS здесь не дополнительная защита, а единственная. Если политика написана неправильно, никакой другой слой ошибку не поймает.
Хорошая новость: правила простые. Плохая: ошибиться в них легко, а приложение при этом продолжает работать как ни в чём не бывало.
Как это устроено
RLS включается на таблицу. Пока он выключен, таблицу видит любой, у кого есть публичный ключ, то есть весь интернет. Когда включён и политик нет, не видит никто, кроме сервисной роли. Политики открывают доступ обратно, строка за строкой.
У политики две части. using решает, какие существующие строки можно видеть, менять и удалять. with check решает, какие строки можно записать — при вставке и после обновления. Политики одной команды складываются через «или»: если одна разрешает всё, остальные уже не важны.
alter table notes enable row level security;
create policy "notes: read own" on notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "notes: insert own" on notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
Обёртка (select auth.uid()) — не украшение: так Postgres вычисляет идентификатор один раз на запрос, а не на каждую строку. На больших таблицах разница заметна. И поставь индекс на user_id — политика по сути добавляет условие в каждый запрос.
Типичные ошибки
То, что мы находим чаще всего:
- RLS включён не везде. Таблицу добавили позже, через миграцию или руками в редакторе, и про неё забыли. Список таблиц без RLS даёт
select tablename from pg_tables where schemaname = 'public' and not rowsecurity;. - «Вошёл — значит можно». Политика
using (auth.role() = 'authenticated')пускает к чужим данным любого зарегистрированного. Регистрация бесплатная, так что это почти то же, что не закрывать вовсе. - Вставка без
with check. Читать чужое нельзя, а записать строку с чужимuser_id— можно. Так появляются заказы и сообщения «от имени» другого человека. - Роль из
user_metadata. Эти поля человек меняет сам через клиентскую библиотеку. Роли и тарифы — только вapp_metadataили в отдельной таблице, которую пишет сервер. - Представления. Обычный
viewвыполняется с правами владельца и обходит RLS базовых таблиц. На Postgres 15+ нуженwith (security_invoker = true), иначе представление открывает то, что таблица закрывает. - Функции
security definer. Их можно вызвать через RPC, и внутри RLS не действует. Каждая такая функция должна сама проверять, кто её вызвал. - Storage. Файлы в бакетах защищаются политиками на
storage.objects, отдельно от таблиц. Закрытые таблицы и открытый бакет со сканами паспортов — обычная история.
Как проверить
Глазами политики проверять бесполезно: они выглядят правильно, пока не попробуешь их обойти. Проверять нужно запросами.
Первый способ — через клиент. Заведи двух тестовых пользователей, из-под первого создай записи, из-под второго попробуй прочитать, изменить и удалить их по идентификатору. Затем попробуй вставить строку с user_id первого. Каждая попытка должна вернуть пустой результат или ошибку.
Второй способ — прямо в SQL, внутри транзакции: set local role authenticated, затем set local request.jwt.claims с нужным sub, затем обычные запросы. Так удобно гонять проверки в CI, не поднимая фронтенд. Для этого есть pgTAP, но хватит и набора SQL-файлов, которые падают при неожиданном результате.
Третье — проверка на полноту. Тест, который перечисляет все таблицы схемы public и падает, если у какой-то выключен RLS или нет ни одной политики, ловит самую частую ошибку — забытую новую таблицу.
Где держать логику, которой мало RLS
Политики хороши для «чьё это». Для «можно ли это сделать сейчас» — хватает ли баланса, не закрыт ли заказ, не превышен ли лимит — им тесно. Такие операции лучше уносить в серверную функцию или edge function с сервисным ключом, а таблицу на запись для клиента закрыть совсем.
Если хочешь, чтобы кто-то со стороны попробовал обойти твои политики до того, как это сделают пользователи, SUBXCAT делает такую проверку и оставляет после неё тесты.