В первой части было разобрано, как настроить RLS в Go, почему is_local=true спасает от утечек в PgBouncer, и как покрыть это интеграционными тестами. Если вы еще не настроили базовую изоляцию, начните оттуда.
Сегодня пойдем глубже. Не будем говорить о синтаксисе. Поговорим об архитектуре. О том, почему RLS - это не просто "удобный фильтр", а нативный механизм инкапсуляции, который решает проблемы распределенных систем и рисков безопасности прямо в слое данных, не раздувая Ops-сложность до сотен схем и баз.
Кому и зачем читать?
Junior/Middle: Поймете, почему RLS - это архитектурный выбор, а не просто WHERE tenant_id = ?. Узнаете про TOCTOU и как его избежать.
Senior/Architect: Увидите паттерны для highload (партиционирование + RLS) и production-грабли (матвьюхи, репликация).
TL;DR ключевых идей:
RLS = встроенный в БД security layer, а не фильтр.
Паниковать нужно только для чувствительных данных.
10k партиций - можно, но сложно; Hash partitioning - золотая середина.
RLS защищает от межтенантных утечек при SQL-инъекциях, но не от инъекций в самой политике.
1. Reliability: Философия Panic и Fail Fast
В первой части мы установили жесткое правило, нет контекста - нет транзакции. Но почему так радикально?
В мире разработки есть два подхода к ошибкам:
Graceful Degradation (Изящная деградация): "Сервис авторизации тупит? Ну, покажем пользователю кешированный профиль или пустой список, лишь бы не 500-ка".
Fail Secure (Отказ в безопасное состояние): "Не уверен в правах пользователя. Останавливаю работу немедленно".
Для UI "изящная деградация" - это хорошо. Для слоя данных - катастрофа.
Почему был выбран Panic?
Представьте, что миддлварь из-за бага пропустил запрос без tenant_id.
Если "сгладим" углы и вернем NULL, бизнес-логика может интерпретировать это, как "Новый пользователь без заказов" и начать создавать дубликаты записей или, хуже того, перезаписывать глобальные настройки.
RLS позволяет реализовать принцип Transaction Suicide. Если база данных видит, ��то контекст безопасности нарушен (переменная не задана или имеет неверный формат), она убивает транзакцию.
-- Пример "параноидальной" функции контекста
CREATE OR REPLACE FUNCTION current_tenant_strict() RETURNS uuid AS $$
BEGIN
-- Не возвращаем NULL. Стреляем на поражение.
RETURN current_setting('app.tenant_id')::uuid;
EXCEPTION
WHEN OTHERS THEN
RAISE EXCEPTION 'RLS Policy Violation: Security Context Missing';
END;
$$ LANGUAGE plpgsql STABLE;
/*
Практический совет: Не применяйте панику слепо.
Используйте градацию:
1. PANIC (current_tenant_strict()): для платежей, персональных данных или любых других важных данных
2. STRICT (возвращаем NULL UUID): для обычных бизнес-данных
3. LAX (возвращаем NULL): для публичных каталогов, аналитики
Пример для публичных данных:
CREATE FUNCTION current_tenant_or_public() RETURNS uuid AS $$
BEGIN
RETURN COALESCE(
NULLIF(current_setting('app.tenant_id', true), '')::uuid,
'00000000-0000-0000-0000-000000000000'::uuid
);
END;
$$ LANGUAGE plpgsql;
*/Это переводит ошибки безопасности из разряда "тихих багов" в разряд "громких инцидентов", которые чинятся в первые минуты после деплоя.
Напоминание из первой части: Чтобы эта защита работала в пулере соединений (PgBouncer), критически важно использовать
set_config(..., is_local => true). Без этого переменная "утечет" к соседу. Подробнее механика расписана в прошлой статье.
2. Consistency: Убийца Race Conditions
Вот где RLS сияет по-настоящему. Большинство разработчиков даже не осознают, что у них есть дыры класса TOCTOU (Time-of-Check to Time-of-Use).
Сценарий атаки
У вас есть документ. Юзер Алиса хочет его удалить. Админ одновременно отзывает у Алисы права.
Типичный код приложения:
// 1. Time of Check
if !user.CanEdit(docID) {
return Error("Нет прав") // (A)
}
// ... в эту наносекунду админ коммитит транзакцию по отзыву прав ...
// 2. Time of Use
db.Delete(docID) // Упс, документ удален, хотя прав уже нетМежду точкой А и точкой Б есть временное окно. В распределенной системе (где проверка прав может быть вообще в микросервисе Auth) это окно может достигать миллисекунд.
Чтобы закрыть его в коде, нужны распределенные локи (Redis Redlock) или уровень изоляции SERIALIZABLE для всей БД (привет, дедлоки).
Решение RLS
RLS переносит проверку (Check) внутрь действия (Use).
Когда вы делаете:
DELETE FROM documents WHERE id = 1;Postgres выполняет это атомарно:
Блокирует строку (Row Lock) для изменения
В той же транзакции, в том же кванте времени проверяет политики RLS (
USING)Если политика теперь возвращает
false, строка для операцииDELETEперестает существоватьСчетчик удаленных строк: 0
Вы получаете гарантии консистентности на уровне БД без единого лока в приложении.
До RLS:
Запрос → Проверка прав в коде → Запрос к БД → Race condition возможен
После RLS:
Запрос → Установка контекста → Запрос к БД → Проверка прав в БД (атомарно)
Проблема | Без RLS | С RLS |
Race conditions | Требует локов | Решено на уровне БД |
SQL-инъекции | Полная утечка | Только свои данные |
Сложность кода | Высокая | Низкая |
Решение RLS (с оговорками)
RLS переносит проверку (Check) внутрь действия (Use), что закрывает базовый класс TOCTOU-уязвимостей.
Но важно:
Защита работает в пределах одной БД (не поможет в микросервисной архитектуре)
Зависит от уровня изоляции транзакций (лучше всего с READ COMMITTED)
Не заменяет блокировки для сложных бизнес-правил
Для 99% случаев RLS достаточно. Для финансовых транзакций требуется SERIALIZABLE изоляция или FOR UPDATE с соблюдением порядка локирования.
Defense in Depth: SQL Injection
И еще один бонус. Даже если (не дай бог) у вас есть SQL-инъекция. Злоумышленник пишет: ' OR '1'='1. Приложение выполняет: SELECT * FROM orders WHERE id = '' OR '1'='1'.
В обычной системе это "Game Over", вытекли все заказы. В системе с RLS хакер увидит... все свои заказы. Потому что неявный AND tenant_id = current_setting(...) добавляется базой данных поверх любого WHERE в запросе.
Edge Case (Гонки в политиках): Если ваша политика содержит подзапросы (например, tenant_id IN (SELECT ...)), вы снова в опасности из-за MVCC.
Решение: Держите политики простыми (id = $1). Если нужны подзапросы, используйте FOR UPDATE внутри них (чтобы залочить строки прав) или уровень изоляции SERIALIZABLE (дорого, но надежно).
3. Performance: Как выжить с RLS на объемах
В первой статье было упомянуто, что RLS добавляет оверхед. Если у вас таблица orders на 100 млн. строк, Postgres должен проверить права для каждой строки. Это больно.
Решение - Partitioning. Но не простое, а "золотое".
Шаг 0: Индексы (до партиционирования)
-- Прежде чем партиционировать, убедитесь, что есть индекс:
CREATE INDEX CONCURRENTLY idx_orders_tenant ON orders(tenant_id);
-- Для 90% проектов этого хватит на годыШаг 1: List partitioning (когда клиентов < 100)
Если у вас SaaS для корпораций (50-100 крупных клиентов), идеально подходит PARTITION BY LIST (tenant_id).
Каждый клиент живет в своей физической таблице. Изоляция идеальная.
Когда пора переезжать на Hash?
Количество партиций > 100
Время планирования запроса > 100ms
Размер каталога (
pg_class) > 500MBVACUUMодной таблицы длится часами
Шаг 2: Hash partitioning (когда клиентов > 1000)
Если у вас 100,000 пользователей, создавать 100,000 партиций (List) - это убить базу. Заголовок статьи провокационный: 10,000 партиций значительно увеличат время планирования запросов, а VACUUM будет работать вечно.
Золотая середина - Hash Partitioning.
Вы создаете 128 (или 256) buckets.
CREATE TABLE orders (
id uuid,
tenant_id uuid,
payload jsonb
) PARTITION BY HASH (tenant_id);
-- Создаем 128 партиций
CREATE TABLE orders_000 PARTITION OF orders FOR VALUES WITH (MODULUS 128, REMAINDER 0);
...
CREATE TABLE orders_127 PARTITION OF orders FOR VALUES WITH (MODULUS 128, REMAINDER 127);Пользователи "размазываются" по этим 128 таблицам.
Шаг 3: Альтернативы (когда выросли)
TimescaleDB для временных данных
Citus для горизонтального масштабирования
BRIN-индексы если данные упорядочены
4. Магия Run-Time Partition Pruning: Развенчать "магию"
Как оптимизатор понимает, какую партицию читать?
Обычно Pruning работает на этапе планирования. Здесь Postgres использует Run-Time Pruning (на этапе Executor Start). Считая хеш от current_setting, он исключает все остальные 127 таблиц.
Как проверить, работает ли pruning?
EXPLAIN (ANALYZE, VERBOSE)
SELECT * FROM orders
WHERE tenant_id = current_setting('app.tenant_id')::uuid;
-- В выводе ищите:
-- Runtime Pruning: true работает!
-- Partitions Selected: 1 of 128 отлично!
-- Важно: pruning НЕ работает, если:
-- 1. Используете подзапросы в WHERE
-- 2. Вызываете VOLATILE-функции
-- 3. Делаете JOIN между партиционированными таблицами4. Грабли реализации (Senior Checklist)
В завершение, краткий чеклист "граблей" (подробнее о некоторых было в первой части):
Superuser Blindness: Помните, что postgres и роли с BYPASSRLS игнорируют политики. Всегда тестируйте под ролью app_user.
Materialized Views: Критично. RLS не работает при чтении из мат-вьюхи! Это физический снимок, созданный от имени владельца.
Решение: Не давайте прямой доступ к MatView. Оберните её в обычную View с security_invoker=true (PG 15+) и явным фильтром.
Leakproof @ Managed Postgres: В облаках (Yandex Cloud, AWS RDS) у вас часто нет прав суперюзера, чтобы пометить функцию как LEAKPROOF.
Решение: Используйте стандартные операторы или расширения (типа pg_trgm), которые уже помечены как безопасные вендором.
Logical Replication (DWH): WAL-логи содержат "сырые" данные. Если вы реплицируете базу в DWH/BigQuery, RLS там не работает магическим образом. Настраивайте безопасность на приемнике.
Hard Migrations: На таблицах 1TB+ ALTER TABLE с включенным RLS может вызвать пересчет политик. Лайфхак: BEGIN; DISABLE RLS; ALTER ...; ENABLE RLS; COMMIT;. Делайте это строго внутри транзакции, иначе в момент отключения RLS ваши данные будут голыми для всех!
Заключение
Row Level Security - это отличный инструмент. Выбирайте его, исходя из своих задач и бизнес-требований. Он требует дисциплины (не забывать is_local), понимания кишок базы (Pruning, Transaction Isolation) и смелости (Fail Fast). Взамен же вы получаете архитектуру, в которой безопасность данных гарантирована фундаментальными свойствами базы данных.
Чего НЕ делать с RLS (чтобы не выстрелить себе в ногу)?
1. Не используйте подзапросы в политиках (риск TOCTOU)
-- ПЛОХО:
CREATE POLICY bad_policy ON documents
FOR SELECT USING (
EXISTS (SELECT 1 FROM permissions WHERE user_id = current_user_id())
);
-- ХОРОШО:
CREATE POLICY good_policy ON documents
FOR SELECT USING (owner_id = current_user_id());2. Не давайте прямой доступ к материализованным представлениям
3. Не забывайте про VACUUM на партициях
Проверь свой проект (5 минут):
Включен ли RLS на продовых таблицах?
\dt+Есть ли у сервисных аккаунтов BYPASSRLS?
\duИспользуете ли вы
is_local=trueс PgBouncer?Есть ли индекс на tenant_id?
Тестируете ли вы запросы без контекста?
Куда двигаться дальше?
Для практики: Возьмите свой проект, включите RLS на одной тестовой таблице
Для изучения: Почитайте про
security_invokerв PG15+ иpg_auditДля мониторинга: Настройте алерты на запросы с tenant_id = NULL
RLS - это не серебряная пуля, но мощный инструмент в арсенале. Начните с малого, добавляйте постепенно, и ваша архитектура станет прочнее с каждым релизом. Пишите безопасный код, используйте Panic с умом и не бойтесь партицирования.
