Когда строишь SaaS, один из первых вопросов: как изолировать данные клиентов? Три популярных подхода:
- Schema-per-tenant - у каждого клиента свой Postgres-schema. Хорошо для крупных enterprise, но дорого на старте.
- Database-per-tenant - отдельная база на клиента. Максимум изоляции, максимум боли в управлении.
- Row-Level Security (RLS) - одна база, одна schema, но каждая строка принадлежит кому-то. Postgres сам фильтрует.
Для большинства B2B SaaS правильный ответ - RLS. Дёшево, безопасно, масштабируется до тысяч тенантов. Разберём, как это сделать в Supabase.
Шаг 1. Добавляем user_id на каждую таблицу#
ALTER TABLE lots ADD COLUMN user_id uuid NOT NULL
REFERENCES auth.users(id) ON DELETE CASCADE;
CREATE INDEX idx_lots_user_id ON lots (user_id);NOT NULL - критично
Без NOT NULL можно случайно вставить строку без user_id, и RLS-политика пропустит
её для всех. Это утечка. Делайте NOT NULL с самого начала.
Шаг 2. Включаем RLS#
ALTER TABLE lots ENABLE ROW LEVEL SECURITY;После этой команды все запросы без политики вернут пустой результат - даже для supabase-admin. Осторожно на проде: сначала политики, потом ENABLE.
Шаг 3. Пишем политики#
Четыре операции - четыре политики (или одна FOR ALL):
CREATE POLICY "select_own_lots" ON lots
FOR SELECT TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "insert_own_lots" ON lots
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
CREATE POLICY "update_own_lots" ON lots
FOR UPDATE TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
CREATE POLICY "delete_own_lots" ON lots
FOR DELETE TO authenticated
USING (user_id = auth.uid());USING - фильтр при чтении (кого вижу).
WITH CHECK - проверка при записи (что могу вставить).
Самая частая ошибка - забыть WITH CHECK на UPDATE. Без него клиент может поменять
user_id на чужой и «передать» строку.
Шаг 4. Защищаем от «забыли user_id в INSERT»#
Если клиентский код забудет проставить user_id, INSERT провалится (ввиду WITH CHECK). Но есть опция проще - триггер, который автопроставляет из сессии:
CREATE OR REPLACE FUNCTION set_user_id_on_insert()
RETURNS trigger AS $$
BEGIN
IF NEW.user_id IS NULL THEN
NEW.user_id := auth.uid();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trg_lots_set_user_id
BEFORE INSERT ON lots
FOR EACH ROW EXECUTE FUNCTION set_user_id_on_insert();Шаг 5. Soft delete + RLS#
Популярный паттерн - не удалять данные, а ставить deleted_at. Чтобы soft-deleted строки не появлялись в UI, запекаем фильтр прямо в RLS:
CREATE POLICY "select_active_lots" ON lots
FOR SELECT TO authenticated
USING (user_id = auth.uid() AND deleted_at IS NULL);Теперь любой SELECT * FROM lots вернёт только живые строки этого юзера. Никаких
.is('deleted_at', null) в каждом запросе приложения.
Шаг 6. Тесты политики#
RLS без тестов - как пароль без 2FA: вроде есть, но толку ноль. Пишите E2E на pgTap
или хотя бы скрипт:
-- Логинимся как user A, создаём лот
SET request.jwt.claims TO '{"sub":"user-a"}';
INSERT INTO lots (title) VALUES ('A''s secret') RETURNING id;
-- Логинимся как user B, пробуем увидеть
SET request.jwt.claims TO '{"sub":"user-b"}';
SELECT * FROM lots; -- Должно вернуть 0 строк
-- B пробует обновить A-лот
UPDATE lots SET title = 'stolen' WHERE id = <A's id>;
-- Должно вернуть "UPDATE 0"Чеклист перед выкатом RLS в прод
-
NOT NULLнаuser_idвсех тенантных таблиц -
ENABLE ROW LEVEL SECURITYвезде - Политики на SELECT / INSERT / UPDATE / DELETE
-
WITH CHECKна UPDATE - Soft delete в
USINGесли применимо - Тесты, симулирующие «другого юзера»
- Admin-роль обходит RLS только через service_role ключ (не public)
Что делать с foreign keys между тенантами#
Есть связанные таблицы - например lot_results ссылается на lots. RLS на lot_results:
CREATE POLICY "select_own_results" ON lot_results
FOR SELECT TO authenticated
USING (
user_id = auth.uid()
AND EXISTS (
SELECT 1 FROM lots
WHERE lots.id = lot_results.lot_id
AND lots.user_id = auth.uid()
)
);Дополнительная проверка через EXISTS защищает от ситуации, где у кого-то получится вставить lot_result со своим user_id, но чужим lot_id.
Производительность#
RLS применяется как дополнительный фильтр к WHERE. Если на user_id есть индекс - почти бесплатно. Если нет - Postgres будет делать Seq Scan на каждый запрос.
-- Обязательно для multi-tenant
CREATE INDEX idx_lots_user_id_created_at
ON lots (user_id, created_at DESC);Вывод#
RLS - это не волшебная галочка, а полноценная архитектурная практика. Делайте её
частью каждого CREATE TABLE с первого дня. Мы в TenderCRM
пишем RLS в той же миграции, что и саму таблицу - иначе забудется.
Жаңалықтар тізімі
Жаңа мақалалар және кейс талдаулары - екі аптада бір рет
Спамсыз және маркетингтік хаттарсыз. Тек техника мен нақты тапсырмалар. Бір басумен жазылудан бас тартуға болады.
