Представим небольшую таблицу заметок по задачам: у каждой записи есть автор, заголовок и статус. Её читают два клиента: Анна и Борис. Мы поднимаем PostgREST, он честно рисует endpoint /tasks, Анна открывает /tasks?id=eq.17 и видит свою запись. Всё выглядит почти готовым. Потом Борис подставляет в URL тот же 17, и вопрос уже не про удобство API, а про то, где именно в этой цепочке живёт граница доступа.
Ответ неприятен тем, что в самом PostgREST этой границы нет. Он превращает HTTP-запрос в SQL, но решение «можно или нельзя» принимает PostgreSQL. Сервис лишь передаёт ему, от чьего имени действовать. Значит, безопасность API — это ваша модель ролей, прав и политик, а не побочный продукт того, что endpoint создался сам. Автоматически появляется адрес, а не продуманная модель данных. Дальше разберём, как эта модель собирается из частей.
Две роли, которые нельзя путать: кто подключается и кто действует
В документации по аутентификации PostgREST есть простая формула: сервис аутентифицирует запрос, а авторизует действия PostgreSQL (PostgREST: Authentication). Из неё вытекает схема с тремя видами ролей, и каждую стоит держать отдельно.
- Authenticator — роль, под которой процесс PostgREST открывает соединения с базой. У неё намеренно скромные права: по сути, только возможность войти и переключиться в другие роли.
- Anonymous — роль для запросов без подходящего токена. Это то, что увидит любой, кто знает адрес.
- Роли пользователей или групп — например,
app_user. В них сервис переключается на время одного запроса, если JWT указывает такую роль.
Это важное различие: «технический пользователь сервера» и «тот, кто пришёл с запросом» — разные сущности. Authenticator не должен уметь читать таблицы. Он лишь имеет право стать другой ролью. Если выдать ему права на данные «чтобы заработало», вы создадите роль, которая видит всё, и любая ошибка в переключении превратится в утечку.
Разницу удобно увидеть на двух запросах. Первый приходит без токена: PostgREST входит в базу как authenticator и переключается в настроенную анонимную роль. Второй приходит с токеном, где указана роль app_user: переключение идёт уже в неё. Само соединение с базой в обоих случаях открыто одной и той же ролью, а права, по которым выполняется SQL, оказываются разными. Поэтому вопрос «что может этот запрос» нужно задавать не про authenticator, а про ту роль, в которую он переключился (PostgREST: Authentication).
У такого разделения есть и практическое следствие для отладки. Если запрос неожиданно получил отказ, смотреть нужно на права действующей роли: ей не хватает usage на схему api, права на таблицу или подходящей политики. Если же что-то неожиданно разрешено, ищите, какая роль указана в токене и не слишком ли широкие права ей выданы. Роль подключения в этой цепочке почти никогда не виновата, если вы не дали ей лишнего.
Для нашего примера минимальная заготовка выглядит так. Имена условные, пароль подставьте свой:
create role authenticator noinherit login password 'сюда-свой-пароль';
create role web_anon nologin;
create role app_user nologin;
grant web_anon to authenticator;
grant app_user to authenticator;noinherit здесь не украшение: authenticator не наследует права ролей, в которые ему разрешено переключаться, а только получает возможность это сделать. Роли web_anon и app_user без права входа (nologin) — они существуют только как профили полномочий, под ними никто не логинится напрямую.
Как JWT выбирает роль на время одного запроса
Согласно документации, JWT может задавать роль на время HTTP-запроса, а если подходящего токена нет, используется настроенная anonymous role (PostgREST: Authentication). Поэтому у одного и того же URL могут быть разные «версии реальности»: для запроса без токена база отвечает от лица web_anon, для запроса с токеном — от лица роли из его полезной нагрузки.
Полезная нагрузка токена Анны может выглядеть так (подпись и секрет здесь, разумеется, не показаны — секрет живёт только в конфигурации сервиса и у того, кто выпускает токены):
{
"role": "app_user",
"user_id": "anna",
"exp": 1893456000
}Поле role определяет, в какую роль переключится сервис. Остальные поля — обычные claims; позже они пригодятся политикам строк. Срок действия exp стоит задавать коротким: токен — это временный пропуск, а не постоянный ключ. Секрет подписи не публикуйте ни в репозитории, ни в примерах для коллег, ни в логах.
Здесь же граница двух ошибок, которые часто смешивают. Анонимный доступ — это сознательное решение показать что-то без входа, например публичный каталог. Административная роль — это совсем другое: роль, которая что-то меняет в структуре или читает всё подряд. Токен пользователя не должен указывать ни на то, ни на другое «на всякий случай». Анонимной роли выдавайте только то, что вы готовы показать любому прохожему, а административные действия — миграции, создание таблиц, управление ролями — выполняйте отдельным подключением к базе вне HTTP API, не через PostgREST.
До защиты строк: что уже решают права на таблицу
Прежде чем включать RLS, полезно увидеть, как далеко заходит обычная система прав. Таблица:
create schema api;
create table api.tasks (
id bigint generated always as identity primary key,
owner_id text not null,
title text not null,
status text not null default 'new'
);
grant usage on schema api to app_user;
grant select, insert, update on api.tasks to app_user;Мы выдали app_user право читать, добавлять и менять, но не удалять. Это уже честная граница по операциям: запрос DELETE от имени этой роли PostgreSQL отклонит, и PostgREST вернёт ошибку доступа. Анонимной роли мы ничего не дали, значит, запрос без токена тоже не получит данные.
Однако права на таблицу работают на уровне «всё или ничего» по строкам. Если app_user может читать api.tasks, то он может читать все её строки. Именно поэтому и Анна, и Борис с равными ролями видят чужую запись по идентификатору. Приложение-интерфейс, возможно, показывает им только свои задачи, но API не обязан ходить через интерфейс. Любой, у кого есть валидный токен, может написать запрос руками, и вы получите ровно то, что разрешено базой, а не то, что нарисовано в вёрстке.
Полезно проверить это на одной задаче. Допустим, в таблице три строки: две принадлежат Анне и одна Борису. Права на таблицу у обеих ролей одинаковые, и запрос GET /tasks с токеном любого из них вернёт все три строки. Права ответили на вопрос «можно ли читать эту таблицу», но не на вопрос «какие строки именно». Пока второго вопроса в базе нет, граница между пользователями держится только на аккуратности клиента, а клиент как раз и есть та сторона, которой вы не управляете.
Из этого следует порядок работы: сначала вы решаете, какие операции вообще нужны роли, и выдаёте только их, затем отдельно описываете, какие строки доступны. Смешивать эти шаги не стоит: широкий grant без политик при выключенном RLS открывает всю таблицу целиком.
Представления как ограниченное окно
Не всегда нужно открывать саму таблицу. Часто разумнее опубликовать представление, которое отдаёт только нужные столбцы и скрывает служебные. Скажем, если в таблице появится поле с внутренней пометкой модератора, представление без него не выдаст его случайно, а права на базовую таблицу ролям API можно не давать вовсе. Это упрощает и дальнейшие изменения: структуру внутри можно перестраивать, пока представление сохраняет контракт.
Но осторожно с ожиданиями: представление ограничивает набор столбцов и логику выборки, а вопрос «чьи строки» всё равно остаётся. Как именно сочетаются представления и политики строк с владельцем представления, зависит от конфигурации и версии PostgreSQL. Если собираетесь опираться на такую связку, проверьте её на тестовых данных для своей версии по документации, а не по догадке.
Включаем RLS: default deny и первая политика
Row-level security меняет модель: после включения для таблицы без подходящей политики действует запрет по умолчанию (PostgreSQL: Row Security Policies). Это приятное свойство: забытая политика ведёт к закрытым данным, а не к открытым. Но то же свойство и ловушка при отладке: сразу после enable row level security все запросы вернут пустой результат, и это не поломка.
alter table api.tasks enable row level security;
create policy tasks_select_own on api.tasks
for select to app_user
using (owner_id = current_setting('request.jwt.claims', true)::json->>'user_id');Политики можно задавать для отдельных команд и ролей — это тоже следует из документации PostgreSQL, — поэтому мы указали for select и to app_user. Условие using отбирает строки, видимые для чтения: в нём мы сравниваем владельца записи со значением user_id из токена. Здесь я опираюсь на то, что PostgREST передаёт claims токена в настройку запроса; точное имя этой настройки в вашей версии проверьте по документации PostgREST перед использованием, так как оно зависело от выпуска. Если имя окажется другим, политика не найдёт значения и, скорее всего, ничего не покажет — то есть откажет безопасным способом.
Теперь команды записи. Для вставки и изменения нужны свои политики, и в них есть тонкость:
create policy tasks_insert_own on api.tasks
for insert to app_user
with check (owner_id = current_setting('request.jwt.claims', true)::json->>'user_id');
create policy tasks_update_own on api.tasks
for update to app_user
using (owner_id = current_setting('request.jwt.claims', true)::json->>'user_id')
with check (owner_id = current_setting('request.jwt.claims', true)::json->>'user_id');using отвечает на вопрос «какие существующие строки вы вообще можете затронуть», with check — «какой вид должна иметь строка после записи». Без проверки в update Анна могла бы изменить поле owner_id на чужое и «подарить» запись Борису. Без проверки в insert — создавать записи от чужого имени. Поэтому для записи условие чаще нужно в обеих частях.
Разберём на конкретном случае. Анна отправляет PATCH /tasks?id=eq.5 с телом {"owner_id": "boris"}, а задача 5 её собственная. Условие using пропускает строку: сейчас она принадлежит Анне. Но после изменения владельцем стал бы Борис, и with check это не пропустит, так что запрос завершится отказом, а запись останется у Анны. Если бы в политике было только using, смена владельца ничем не блокировалась бы, и это как раз тот случай, когда политика выглядит правильной, а дыра остаётся.
Для вставки логика проще: существующих строк ещё нет, поэтому using не нужен, а with check проверяет итоговую строку. Строка с owner_id чужого пользователя до таблицы не дойдёт. Для чтения, наоборот, нужен только using: проверять там нечего, ведь ничего не записывается (PostgreSQL: Row Security Policies).
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Прямой запрос к чужой записи, минуя интерфейс
Теперь вернёмся к исходной сцене. Токен Анны, запрос GET /tasks?id=eq.17, где задача 17 принадлежит Борису. Сервис переключится в app_user, сформирует выборку по id = 17, а PostgreSQL добавит к ней условие политики. Строка не пройдёт фильтр, и результат — пустой список, а не «запрещено». Это нормально и даже полезно: API не подтверждает существование чужой записи.
Для записи картина похожа. PATCH /tasks?id=eq.17 от Анны не найдёт подходящих строк для изменения и ничего не затронет, а попытка вставить запись с чужим owner_id упрётся в with check и получит отказ.
Важно понимать, что именно мы проверили. Политика сработала для ролей, на которые она написана и под которыми идёт запрос. Это не доказывает, что любые пути к таблице защищены. Документация PostgreSQL прямо называет исключения, и на них стоит остановиться отдельно.
Кто RLS не подчиняется
- Superuser и роли с BYPASSRLS обходят политики. Если вы по ошибке направите токен в такую роль или подключите PostgREST под ней, никакой фильтрации не будет.
- Владелец таблицы обычно тоже обходит RLS, если не включить
FORCE ROW LEVEL SECURITY. Поэтому не делайте владельцем таблицы роль, под которой выполняются запросы API. Владельцем должна быть отдельная административная роль, которую HTTP-запросы не используют. - TRUNCATE и REFERENCES не подчиняются построчной фильтрации. Значит, права на эти операции ролям API лучше вообще не выдавать, а внешние ключи на защищённые таблицы продумывать отдельно.
Эти пункты — практическая причина разделять роли. Миграции, владение объектами и обслуживание — одно подключение. Запросы клиентов — другое, без привилегий, которые RLS обходят. Смешение их «для удобства» и превращает хорошо написанную политику в декорацию.
Типичный сценарий ошибки выглядит безобидно. Разработчик создаёт таблицу под своей учётной записью, включает RLS, пишет политики и проверяет API токеном Анны: всё работает. Затем для удобства он указывает эту же учётную запись в подключении сервиса или в токене для отладки, и внезапно все строки видны каждому. Политики никуда не делись, просто владелец таблицы без FORCE ROW LEVEL SECURITY их не соблюдает, а роль с BYPASSRLS не соблюдает тем более (PostgreSQL: Row Security Policies).
Поэтому в проверочный набор полезно включить запрос от имени роли, которой пользуются реальные клиенты, а не только от имени того, кто создавал схему. Если результаты расходятся, значит, вы проверяли не ту границу.
Анонимный доступ как отдельное решение
Допустим, часть данных публична: например, перечень опубликованных задач без личных полей. Не стоит делать для этого вид «ослабленной» роли пользователя. Заведите отдельное представление или таблицу и выдайте web_anon только на неё:
grant usage on schema api to web_anon;
grant select on api.public_tasks to web_anon;Так граница читается напрямую: анонимный клиент видит один объект, и по списку прав это легко проверить. Роль web_anon не получает ни доступа к api.tasks, ни права записи. Если когда-нибудь для неё понадобится больше, это будет заметное отдельное решение, а не побочный эффект правки политики пользователя.
Запуск на VPS: где что живёт
Для небольшого API задача не требует экзотики: PostgreSQL и PostgREST могут работать на одном сервере. Практичная схема — база слушает только локальный интерфейс, PostgREST подключается к ней под ролью authenticator, а наружу выходит только HTTP-порт сервиса, лучше за обратным прокси с TLS. Так порт PostgreSQL не оказывается в открытом интернете, а единственная публичная поверхность — API, чьи границы вы описали ролями.
VPS MAATRIX даёт root-доступ, NVMe-диски и процессоры AMD EPYC, так что установить базу и сервис из пакетов или контейнера можно самостоятельно. Для первых проб с небольшой таблицей и десятком клиентов разумно начать с младших конфигураций, например Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ). Это отправная точка для экспериментов, а не обещание ёмкости: реальную нагрузку покажет замер на ваших запросах. Локацию выбирайте по тому, где находятся ваши клиенты и какие у вас ограничения; в публичном предложении перечислены UK, US, RU и FR. Установка и настройку самой базы подробнее разбирает материал VPS для PostgreSQL: установка и тюнинг под нагрузку.
Одно напоминание про отказоустойчивость: резервное копирование базы — ваша задача, и автоматические копии для всех тарифов не заявлены. Если данные важны, настройте выгрузки сами и проверьте восстановление на отдельной машине.
Когда схема меняется: права должны меняться вместе с ней
Самая частая причина «вчера работало, сегодня утекло или пусто» — миграции. Добавили столбец, новую таблицу, переименовали представление, и права на новый объект не выдавались, либо, наоборот, выдались шире нужного.
PostgREST читает структуру базы при старте и держит её в кэше схемы, так что после изменений схемы сервису нужно сообщить об этом (перезапуск или перезагрузка кэша схемы — механизм зависит от вашей версии и настроек, сверьтесь с документацией PostgREST). Но обновление кэша — это только про видимость объектов в API. Права и политики лежат в PostgreSQL и обновляются своими командами. Поэтому хорошая миграция всегда состоит из двух частей: изменение структуры и согласованное с ним изменение прав.
Практичный порядок для новой таблицы:
- Создать таблицу в схеме
apiили вложить её за представлением. - Включить RLS сразу, пока в таблице нет данных.
- Написать политики для каждой команды, которую вы собираетесь разрешать.
- Выдать роли минимальный набор прав на операции.
- Перечитать схему в PostgREST и прогнать проверочные запросы под двумя разными токенами.
Если порядок нарушить — выдать права раньше политик, — между этими шагами будет окно. При включённом RLS без политик действует запрет по умолчанию, поэтому окно закрыто, но только если RLS уже включён. Вот почему включать его лучше первым делом.
Для отката изменений прав используйте обычные revoke и drop policy и держите их рядом с миграцией как обратный сценарий. Откат структуры без отката прав оставит политики, которые ссылаются на несуществующее, или права на объект, который уже изменился.
Проверочная таблица: один запрос — три ответа
Вместо абстрактной уверенности соберите короткую таблицу ожиданий и прогоняйте её после каждой миграции. Ожидания для нашего примера:
| Запрос | Без токена | Токен Анны | Токен Бориса |
|---|---|---|---|
| GET /tasks?id=eq.17 (запись Бориса) | отказ в доступе | пустой список | запись возвращается |
| PATCH /tasks?id=eq.17 | отказ в доступе | ничего не изменено | запись обновляется |
| DELETE /tasks?id=eq.17 | отказ в доступе | отказ: нет права на удаление | отказ: нет права на удаление |
Различие в характере ответов тоже информативно. Отказ без токена — из-за отсутствия прав у анонимной роли на таблицу. Пустой список для Анны — из-за политики строк. Отказ на удаление для обоих — из-за того, что права на DELETE никто не получал. Три разных механизма дают три разных ответа, и если один из них вдруг изменится, вы заметите это сразу.
Что API не решает за вас
PostgREST подбирает endpoint под то, что вы ему показали. Он не знает, что owner_id — это граница владения, а status — служебное поле, которое пользователю нельзя менять самому. Если дать право update на всю таблицу, любой клиент сможет изменить любой столбец собственной строки, включая те, которые вы считали внутренними. Это не недостаток инструмента, а следствие того, что модель данных рисуете вы. Используйте права на столбцы или представления, когда часть полей должна оставаться недоступной для записи, и описывайте политики так, чтобы они отражали бизнес-правило, а не только владельца.
Поэтому смысл проектирования здесь в вопросах до команд: кто эти клиенты, какие операции им действительно нужны, какие строки они вправе видеть, какие поля вправе менять. Ответы превращаются в роли, права и политики, а PostgREST лишь аккуратно доставляет запросы до этой логики и ответы обратно.
Финальный опыт: тот же URL, другой человек
Вернёмся к сцене с идентификатором 17. Теперь у нас есть всё, чтобы повторить её без неприятных сюрпризов. Один и тот же HTTP-запрос — GET /tasks?id=eq.17 — отправляется трижды. Без токена база отвечает от имени web_anon, у которого нет прав на таблицу: отказ. С токеном Анны сервис переключается в app_user, политика сравнивает владельца с её user_id, не находит совпадения и отдаёт пустой список. С токеном Бориса условие выполняется, и строка возвращается целиком.
URL не менялся, код приложения не вмешивался, никто не правил интерфейс. Разные результаты возникли из того, что в базе разными были роли и данные токена. Именно так границы API перестают быть договорённостью между фронтендом и бэкендом и становятся свойством самих данных. Если ожидания сходятся с таблицей выше, модель работает так, как вы её задумали, а любое расхождение — повод открыть список прав и политик, а не искать виноватого в маршруте запроса.