В первой части было разобрано, как настроить RLS в Go, почему is_local=true спасает от утечек в PgBouncer, и как покрыть это интеграционными тестами. Если вы еще не настроили базовую изоляцию, начните оттуда.

Сегодня пойдем глубже. Не будем говорить о синтаксисе. Поговорим об архитектуре. О том, почему RLS - это не просто "удобный фильтр", а нативный механизм инкапсуляции, который решает проблемы распределенных систем и рисков безопасности прямо в слое данных, не раздувая Ops-сложность до сотен схем и баз.

Кому и зачем читать?

Junior/Middle: Поймете, почему RLS - это архитектурный выбор, а не просто WHERE tenant_id = ?. Узнаете про TOCTOU и как его избежать.

Senior/Architect: Увидите паттерны для highload (партиционирование + RLS) и production-грабли (матвьюхи, репликация).

TL;DR ключевых идей:

  1. RLS = встроенный в БД security layer, а не фильтр.

  2. Паниковать нужно только для чувствительных данных.

  3. 10k партиций - можно, но сложно; Hash partitioning - золотая середина.

  4. RLS защищает от межтенантных утечек при SQL-инъекциях, но не от инъекций в самой политике.

1. Reliability: Философия Panic и Fail Fast

В первой части мы установили жесткое правило, нет контекста - нет транзакции. Но почему так радикально?

В мире разработки есть два подхода к ошибкам:

  1. Graceful Degradation (Изящная деградация): "Сервис авторизации тупит? Ну, покажем пользователю кешированный профиль или пустой список, лишь бы не 500-ка".

  2. 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 выполняет это атомарно:

  1. Блокирует строку (Row Lock) для изменения

  2. В той же транзакции, в том же кванте времени проверяет политики RLS (USING)

  3. Если политика теперь возвращает false, строка для операции DELETE перестает существовать

  4. Счетчик удаленных строк: 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) > 500MB

  • VACUUM одной таблицы длится часами

Шаг 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 минут):

  1. Включен ли RLS на продовых таблицах? \dt+

  2. Есть ли у сервисных аккаунтов BYPASSRLS? \du

  3. Используете ли вы is_local=true с PgBouncer?

  4. Есть ли индекс на tenant_id?

  5. Тестируете ли вы запросы без контекста?

Куда двигаться дальше?

  1. Для практики: Возьмите свой проект, включите RLS на одной тестовой таблице

  2. Для изучения: Почитайте про security_invoker в PG15+ и pg_audit

  3. Для мониторинга: Настройте алерты на запросы с tenant_id = NULL

RLS - это не серебряная пуля, но мощный инструмент в арсенале. Начните с малого, добавляйте постепенно, и ваша архитектура станет прочнее с каждым релизом. Пишите безопасный код, используйте Panic с умом и не бойтесь партицирования.