Паттерны RLS в PostgreSQL для многотенантных приложений
Изучите построчный контроль доступа (RLS) в PostgreSQL: практичные паттерны для изоляции арендаторов и правил ролей, чтобы доступ проверялся в базе, а не только в приложении.

Почему защита на уровне базы данных важна в бизнес-приложениях
В бизнес-приложениях часто встречаются правила вроде «пользователи видят только данные своей компании» или «только менеджеры могут одобрять возвраты». Многие команды проверяют эти правила в UI или в API и думают, что этого достаточно. Проблема в том, что каждый дополнительный путь к базе данных — ещё один шанс на утечку: внутренний админ-инструмент, фоновая задача, аналитический запрос, забытый эндпоинт или баг, который пропускает проверку.
Изоляция арендаторов означает, что один клиент (tenant) никогда не сможет прочитать или изменить данные другого клиента, даже случайно. Ролевой доступ означает, что люди внутри одного арендатора всё ещё имеют разные права — агенты, менеджеры, бухгалтерия и т. п. Эти правила легко описать, но трудно поддерживать их полностью консистентными, когда они разбросаны по разным местам.
Построчный контроль доступа в PostgreSQL (RLS) — это функция базы данных, которая позволяет базе решать, какие строки запрос может видеть или изменять. Вместо того чтобы надеяться, что каждый запрос в приложении помнит нужный WHERE-клауза, база сама применяет политики.
RLS не волшебный щит от всего. Он не заменит проектирование схемы, не заменит аутентификацию и не защитит от кого-то с мощной ролью в базе (например, суперпользователя). Также он не предотвратит логические ошибки вроде «кто‑то может обновить строку, которую не может прочитать», если вы не напишете политики и для чтения, и для записи.
Зато вы получаете надёжную страховку:
- Один набор правил для всех путей, которые доходят до базы данных
- Меньше случайных ошибок при выпуске новых фич
- Более прозрачный аудит, потому что правила видны в SQL
- Лучшая защита, если баг в API проскочил
Есть небольшая цена настройки. Нужен единый способ передавать в базу «кто это пользователь» и «какой это арендатор», и нужно поддерживать политики по мере роста приложения. Выгода большая, особенно для SaaS и внутренних инструментов, где на кону чувствительные данные клиентов.
Основы построчного контроля доступа простыми словами
Row-Level Security (RLS) автоматически фильтрует, какие строки запрос может видеть или менять. Вместо того чтобы полагаться на каждый экран, API-эндпоинт или отчёт, чтобы «помнить» правила, база применяет их за вас.
В PostgreSQL вы пишете политики, которые проверяются при каждом SELECT, INSERT, UPDATE и DELETE. Если политика говорит «этот пользователь может видеть только строки арендатора A», то забытая админ-страница, новый запрос или серьёзная правка всё равно получат одни и те же защитные механизмы.
RLS отличается от GRANT/REVOKE. GRANT решает, может ли роль вообще обращаться к таблице (или к столбцам). RLS решает, какие строки внутри этой таблицы доступны. На практике часто используют оба: GRANT, чтобы ограничить, кто может обращаться к таблице, и RLS, чтобы ограничить, к каким именно строкам.
Это работает и в реальной жизни. Представления обычно подчиняются RLS, потому что доступ к базовой таблице всё равно запускает политику. Джойны и подзапросы тоже фильтруются, так что пользователь не сможет «пройти» через соединение к чужим данным. И политика применяется независимо от клиента, который выполняет запрос: код приложения, SQL-консоль, фоновая задача или инструмент отчётности.
RLS хорош, когда нужна строгая изоляция арендаторов, есть несколько способов запроса одних и тех же данных или много ролей, которые делят таблицы (часто в SaaS и внутренних инструментах). Для крошечных приложений с одним доверенным бэкендом или для данных, которые не чувствительны и не покидают один контролируемый сервис, RLS может быть избыточен. Как только появляется больше одной точки входа (админ-инструменты, экспорты, BI, скрипты), RLS обычно окупается.
Начните с картирования арендаторов, ролей и владения данными
Прежде чем писать хоть одну политику, разберитесь, кому что принадлежит. PostgreSQL RLS работает лучше, когда модель данных уже отражает арендаторов, роли и владение.
Начните с арендаторов. В большинстве SaaS-приложений простое правило: в каждой общей таблице, содержащей пользовательские данные, есть tenant_id. Это касается очевидных таблиц вроде счетов, но также и того, о чём часто забывают: вложений, комментариев, логов аудита и фоновых задач.
Дальше — назовите роли, которые реально используются. Держите набор небольшой и понятный: владелец, менеджер, агент, только для чтения. Это бизнес-роли, которые вы позже сопоставите с проверками в политиках (они не равны ролям базы данных).
Затем решите, как распределено владение записями. Некоторые таблицы принадлежат конкретному пользователю (например, приватная заметка). Другие — команде (например, общий почтовый ящик). Смешивание этих подходов без плана приводит к политиками, которые тяжело читать и легко обойти.
Простой способ задокументировать правила — ответить на одинаковые вопросы для каждой таблицы:
- Какой столбец служит границей арендатора (tenant)?
- Кто может читать строки (по ролям и владению)?
- Кто может создавать и обновлять строки (и при каких условиях)?
- Кто может удалять строки (обычно самое строгое правило)?
- Какие исключения допустимы (саппорт, автоматизация, экспорты)?
Пример: для "Invoices" можно разрешить менеджерам просматривать все счета арендатора, агентам — только счета назначенных клиентов, а пользователям только на чтение — видеть, но не редактировать. Решите заранее, какие правила должны быть строгими (изоляция арендатора, удаление) и какие могут быть гибкими (расширенный доступ для менеджеров). Если вы строите в no-code инструменте вроде AppMaster, это картирование помогает согласовать ожидания UI и правила базы.
Паттерны проектирования для многотенантных таблиц
Многотенантный RLS работает лучше, когда таблицы имеют предсказуемую форму. Если в каждой таблице арендаторы указаны по-разному, политики превращаются в головоломку. Последовательная структура делает RLS проще для чтения, тестирования и поддержки.
Начните с выбора одного идентификатора арендатора и используйте его везде. UUIDs популярны, потому что их сложно угадать и их легко сгенерировать. Целые числа тоже подходят, особенно для внутренних приложений. Слаги (например, "acme") удобны для людей, но могут меняться, поэтому используйте их как отображаемое поле, а не как основной ключ.
Для данных, привязанных к арендатору, добавьте tenant_id в каждую таблицу, и по возможности сделайте его NOT NULL. Если строка может существовать без арендатора, это обычно признак проблем: вы смешиваете глобальные и арендаторские данные в одной таблице, что усложняет политики RLS.
Индексация простая, но важная. Большинство запросов в SaaS-файле сначала фильтруют по арендатору, затем по бизнес-полю вроде статуса или даты. Хорошее по умолчанию — индекс по tenant_id, а для горячих таблиц — составной индекс вроде (tenant_id, created_at) или (tenant_id, status) в зависимости от ваших частых фильтров.
Решите заранее, какие таблицы глобальные, а какие — scoped по арендаторам. Частые глобальные таблицы: страны, валюты, определения планов. Tenant-специфичные таблицы: клиенты, счета, тикеты и всё, чем владеет арендатор.
Чтобы правила оставались управляемыми, держите их простыми:
- Таблицы с областью арендатора:
tenant_id NOT NULL, RLS включён, политики всегда проверяютtenant_id. - Глобальные справочники: без
tenant_id, без tenant-политик, только для чтения для большинства ролей. - Контролируемые общие таблицы: выделяйте отдельные таблицы под разные концепты (избегайте смешения глобальных и арендатопных строк).
Если вы используете инструмент вроде AppMaster, такая консистентность окупается: как только tenant_id становится стандартным полем, вы переиспользуете одни и те же паттерны во всех модулях.
Пошагово: создайте первую политику арендатора
Хорошая первая победа с RLS — таблица, которую можно читать только внутри текущего арендатора. Идея простая: даже если кто‑то забудет WHERE в API, база откажется отдавать строки других арендаторов.
Начните с таблицы, где есть tenant_id:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
Когда RLS включён, поведение иногда удивляет: если роль подчиняется RLS и нет подходящей политики, SELECT вернёт ноль строк (записи на запись не пройдут). Это то поведение, которое полезно на старте.
Теперь добавьте минимальную политику на чтение. В примере предполагается, что ваше приложение устанавливает переменную сессии вроде app.tenant_id после логина:
CREATE POLICY invoices_tenant_read
ON invoices
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Далее добавьте правила для записи. В RLS USING контролирует, какие существующие строки вы можете трогать, а WITH CHECK — какие новые значения вам разрешено записывать.
CREATE POLICY invoices_tenant_insert
ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_update
ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_delete
ON invoices
FOR DELETE
USING (tenant_id = current_setting('app.tenant_id')::uuid);
По умолчанию политики PERMISSIVE, то есть любая из них может разрешить доступ. Выберите RESTRICTIVE, когда хотите, чтобы соблюдалось несколько условий одновременно (полезно, если добавляете второй фильтр, например «только активные аккаунты»).
Держите политики маленькими и ориентированными на аудиторию. Вместо одной огромной с множеством OR, создавайте отдельные политики на каждую группу (например, invoices_tenant_read_app_user и invoices_tenant_read_support_agent). Это проще тестировать, проще ревью и безопаснее менять.
Безопасная передача контекста арендатора и пользователя
Чтобы RLS работал, базе нужно знать «кто вызывает» и «к какому арендатору он принадлежит». Политики RLS могут сравнивать строки только с теми значениями, которые база может прочитать во время запроса, поэтому этот контекст нужно передавать в сессию.
Обычный паттерн — устанавливать параметры сессии после аутентификации и читать их в политиках через current_setting(). Приложение подтверждает личность (например, проверяет JWT), затем записывает tenant и user ID в соединение с базой.
-- Выполняется один раз на запрос (или на транзакцию)
SELECT set_config('app.tenant_id', '3f2a0c3e-9c7b-4d3f-9c5c-3c5e9c5d1a11', true);
SELECT set_config('app.user_id', '8d9c6b1a-6b6d-4e32-9c0d-2bfe6f6c1111', true);
SELECT set_config('app.role', 'support_agent', true);
-- В политике
-- столбец tenant_id — UUID
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
Третий аргумент true делает настройку «локальной» для текущей транзакции. Это важно при использовании connection pooling: пул соединений может переиспользовать соединение для другого запроса, поэтому не хочется, чтобы контекст предыдущего запроса остался.
Заполнение контекста из JWT-claim'ов
Если ваш API использует JWT, рассматривайте claims как входные данные, а не как абсолютную истину. Сначала проверьте подпись и срок действия токена, затем скопируйте только необходимые поля (tenant_id, user_id, role) в параметры сессии. Избегайте ситуации, когда клиенты напрямую отправляют эти значения в заголовках или параметрах запроса.
Отсутствующий или неверный контекст: по умолчанию отказ
Проектируйте политики так, чтобы отсутствие настроек приводило к нулю строк.
Используйте current_setting('app.tenant_id', true), чтобы при отсутствии значение возвращало NULL. Кастуйте к нужному типу (например, ::uuid), чтобы неверный формат вываливался быстро. И отклоняйте запрос, если контекст арендатора/пользователя не удалось установить, вместо того чтобы подставлять какой‑то дефолт.
Это сохраняет контроль доступа даже когда запрос обходит UI или появляется новый эндпоинт.
Практичные ролевые паттерны, которые легко поддерживать
Проще всего поддерживать политики RLS, если отделить личность от разрешений. Надёжная основа — таблица users плюс таблица memberships, которая связывает пользователя с арендатором и ролью (или несколькими ролями). Тогда политики отвечают на один вопрос: «Есть ли у текущего пользователя нужное членство для этой строки?»
Держите названия ролей привязанными к реальным действиям, а не к должностям. «invoice_viewer» и «invoice_approver» дольше остаются актуальными, чем «manager», потому что политика проще описывается в терминах действий.
Несколько паттернов ролей, которые остаются простыми по мере роста приложения:
- Только владелец: строка имеет
created_by_user_id(илиowner_user_id) и доступ проверяет точное совпадение. - Только команда: строка имеет
team_id, и политика проверяет, что пользователь состоит в этой команде в рамках того же арендатора. - Только утверждённые: чтение разрешено только когда
status = 'approved', а записи — только апруверам. - Смешанные правила: начните строго, затем добавляйте маленькие исключения (например, «саппорт может читать, но только в рамках арендатора»).
Кросс‑арендаторские админы — это распространённая проблема. Обрабатывайте их явно, а не как скрытую «суперроль». Создайте отдельную концепцию, например platform_admin (глобальная), и требуйте явной проверки в политике. Лучше держать кросс‑арендаторский доступ по умолчанию только для чтения, а на запись ставить более высокую планку.
Документация важнее, чем кажется. Оставьте короткий комментарий над каждой политикой с описанием намерения, а не только SQL. «Утверждающие могут менять статус. Просмотрщики могут только читать утверждённые счета.» Через полгода это поможет безопасно менять политики.
Эти шаблоны применимы и в no-code инструментах вроде AppMaster: UI и API могут развиваться быстро, а правила в базе останутся стабильными, если опираться на членства и понятные роли.
Пример сценария: простой SaaS с счетами и поддержкой
Представьте небольшой SaaS для нескольких компаний. Каждая компания — это арендатор. В приложении есть счета (деньги) и тикеты поддержки (операционная помощь). Пользователи могут быть агентами, менеджерами или саппортом.
Упрощённая модель данных: у каждого счета и тикета есть tenant_id. У тикетов также есть assignee_user_id. Приложение устанавливает текущий tenant и user в сессии базы сразу после логина.
Вот как RLS меняет повседневные риски.
Пользователь из Tenant A открывает экран счетов и пытается угадать ID счёта из Tenant B (или UI случайно его отправляет). Запрос выполнится, но база вернёт ноль строк, потому что политика требует invoice.tenant_id = current_tenant_id. Нет «доступ запрещён» с утечкой — просто пустой результат.
Внутри арендатора роли ещё сильнее сужают доступ. Менеджер видит все счета и тикеты своего арендатора. Агент видит только тикеты, назначенные ему, и, может быть, свои черновики. Здесь команды часто ошибаются в API, особенно когда фильтры опциональны.
Саппорт — особый случай. Ему может понадобиться видеть счета, чтобы помогать клиентам, но он не должен менять чувствительные поля вроде amount, bank_account или tax_id. Практичный паттерн:
- Разрешить
SELECTпо счетам для роли саппорта (всё ещё в пределах арендатора). - Разрешить
UPDATEтолько через «безопасный» путь (например, view с открытыми для редактирования полями или строгая политика обновления, отбрасывающая изменения защищённых полей).
Теперь сценарий «случайный баг в API»: эндпоинт забыл применить фильтр по арендатору после рефактора. Без RLS это может привести к утечке чужих счетов. С RLS база откажется возвращать строки вне контекста сессии арендатора — баг превратится в сломанный экран, а не в утечку данных.
Если вы строите такой SaaS в AppMaster, всё равно храните эти правила в базе. Проверки в UI полезны, но правила базы — то, что сработает, когда что‑то пойдёт не так.
Распространённые ошибки и как их избегать
RLS мощен, но мелкие оплошности могут тихо превратить «безопасно» в «удивительно». Большинство проблем проявляется, когда добавляют новую таблицу, меняют роли или кто‑то тестирует под неправильным пользователем.
Частая ошибка — забыть включить RLS на новой таблице. Вы могли написать аккуратные политики для основных таблиц, затем добавить «notes» или «attachments» и выпустить их с полным доступом. Введите правило: новая таблица — RLS включён и хотя бы одна политика.
Ещё одна ловушка — несоответствие политик для разных действий. Политика, которая разрешает INSERT, но блокирует SELECT, приведёт к эффекту «данные пропадают». Обратный сценарий тоже неприятен: пользователи читают строки, которые не могут создать, и начинают обходные пути в UI. Думайте в терминах потоков: «создал — потом посмотрел», «обновил — потом открыл», «удалил — потом список».
Будьте осторожны с SECURITY DEFINER функциями. Они выполняются с правами владельца функции и могут обходить RLS, если не осторожны. Если используете их, делайте их маленькими, валидируйте входные данные и избегайте динамического SQL, если это не действительно необходимо.
Также не полагайтесь на фильтрацию на стороне приложения, оставляя открытую базу. Даже хорошо сделанные API со временем получают новые эндпоинты, фоновые задачи и админские скрипты. Если роль базы может читать всё, это рано или поздно приведёт к утечке.
Чтобы рано ловить проблемы, делайте практические проверки:
- Тестируйте под той же ролью базы, которую использует продакшен‑приложение, а не под своим админом.
- Добавьте хотя бы один негативный тест на таблицу: пользователь из другого арендатора должен видеть ноль строк.
- Убедитесь, что каждая таблица поддерживает ожидаемые действия:
SELECT,INSERT,UPDATE,DELETE. - Проверьте использование
SECURITY DEFINERи задокументируйте, почему это нужно. - Включите проверку «RLS включён?» в чеклисты ревью кода и миграций.
Пример: если агент саппорта создаёт заметку к счёту, но не может её прочитать — часто это INSERT-политика без соответствующей SELECT-политики (или контекст арендатора не устанавливается для этой сессии).
Короткий чеклист для валидации RLS
RLS может выглядеть правильно на бумаге и всё же падать в реальном использовании. Валидация — это не чтение политик, а попытки их взломать реалистичными аккаунтами и запросами. Тестируйте так, как приложение будет их использовать, а не так, как хочется.
Сначала создайте небольшой набор тестовых идентичностей. Используйте как минимум два арендатора (Tenant A и Tenant B). Для каждого добавьте обычного пользователя и админа/менеджера. Если есть роли «саппорт» или «только для чтения», добавьте и их.
Далее тщательно протестируйте RLS набором повторяемых проверок:
- Выполните основные операции для каждой роли: список строк, получение по id, вставка, обновление, удаление. Для каждой операции пробуйте случаи, которые должны быть разрешены и которые должны блокироваться.
- Проверьте границы арендаторов: от имени Tenant A попытайтесь читать или менять данные Tenant B по известным id. Вы должны получать ноль строк или ошибку доступа, никогда — «какие‑то строки».
- Тестируйте джойны на предмет утечек: соединяйте защищённые таблицы с другими (включая lookup). Убедитесь, что джойн не подтянет строки другого арендатора через внешние ключи или view.
- Проверьте, что отсутствие или неверный контекст блокирует доступ: очистите tenant/user контекст и повторите. «Нет контекста» должно закрывать доступ. Попробуйте также невалидный tenant id.
- Убедитесь в базовой производительности: посмотрите планы запросов и проверьте, что индексы поддерживают шаблон фильтра по арендатору (обычно
tenant_idплюс то, по чему вы сортируете или ищете).
Если тесты дают неожиданный результат, исправляйте политику или логику установки контекста в первую очередь. Не патчьте это в UI и не надейтесь, что база «как‑то удержит» ситуацию.
Дальше: безопасный rollout и поддержание консистентности
Относитесь к RLS как к системе безопасности: вводите аккуратно, регулярно проверяйте и держите правила достаточно простыми, чтобы команда ими пользовалась.
Начните с малого. Выберите таблицы, где утечка будет самой болезненной (платежи, счета, персональные данные, сообщения клиентов) и включите RLS там сначала. Ранние победы лучше большого запуска, который никто не понимает.
Практичная последовательность отката может выглядеть так:
- Сначала ключевые таблицы с явным владением (rows однозначно принадлежат одному арендатору)
- Таблицы с персональными данными (PII)
- Таблицы, которые разделяются, но фильтруются по арендаторам (отчёты, аналитика)
- Таблицы связей и пограничные случаи (many-to-many)
- Всё остальное, когда база правил устоялась
Сделайте тестирование обязательным. Автотесты должны выполняться под разными арендаторами и ролями и проверять ожидаемые изменения. Включите как проверки «должно разрешать», так и «должно блокировать», потому что самые дорогие баги — это тихое переразрешение.
Держите одно место в потоке запроса, где контекст сессии устанавливается до любых запросов. tenant_id, user_id и role должны применяться разово, рано и не подразумеваться позже. Если вы устанавливаете контекст в середине транзакции, рано или поздно вы выполните запрос со старым или отсутствующим контекстом.
Когда вы строите с AppMaster, стандартизируйте способ передачи контекста в базу (например, одни и те же session-переменные для всех эндпоинтов), чтобы политики работали одинаково везде. Если вы используете AppMaster на appmaster.io, RLS всё равно стоит воспринимать как окончательную инстанцию для изоляции арендаторов, даже если вы также ограничиваете доступ в UI.
Наконец, отслеживайте, что ломается. Отказы по авторизации — полезный сигнал, особенно на раннем этапе выката. Мониторьте повторяющиеся отказы и выясняйте, это ли атака, сломанный клиентский поток или слишком строгая политика.
Короткий список привычек, которые помогают RLS оставаться здоровым:
- Подход «по умолчанию отказ», исключения добавляются сознательно
- Понятные имена политик (таблица + действие + аудитория)
- Изменения политик ревьювятся как изменение кода
- Отказы логируются и анализируются во время раннего выката
- Для каждой новой таблицы с RLS добавляется небольшой набор тестов


