- Главная
- Блог
- Как мы прикрутили Row Level Security к pgvector и перестали держать доступ к данным в питоновском коде
Как мы прикрутили Row Level Security к pgvector и перестали держать доступ к данным в питоновском коде

Пятница, конец рабочего дня. Уже собирался домой, звонит операционный директор дистрибьютора автозапчастей. Голос ровный, но по интонации ясно, что дело плохое: «Ребят, у нас ЧП. Менеджер спросил у ассистента про регламент отпусков и получил ссылки на служебку про премии совета директоров. С суммами». Продиктовал ID диалога, повесил трубку.
Дальше был неприятный вечер. В корпоративной памяти лежали десятки тысяч документов, у части из них стояла метка «restricted», и наш RAG честно их находил. А потом — если повезёт — так же честно не отдавал. Фильтр доступа жил в Python-коде. В том конкретном запросе он не отработал.
Мы разрабатываем ИИ-агентов для внутренних процессов компаний, и через такого агента течёт вся память бизнеса: договоры, переписка, регламенты, планёрки. Расскажу, как мы за неделю переехали с фильтрации в приложении на Row Level Security в PostgreSQL и почему это, кажется, единственный способ спать спокойно, когда через pgvector проходит всё подряд.
В чём была ошибка: контроль доступа как последний if в коде
Изначально схема выглядела разумно. Таблица documents — метаданные: owner_id, department_id, visibility, classification. Таблица chunks — векторы и document_id. Запрос из RAG: находим top-k ближайших чанков, подтягиваем документы, применяем в приложении if user.can_see(document): include(...).
Проблема этой архитектуры в том, что «нельзя» здесь — свойство кода, а не данных. Стоит один раз забыть join к documents, один раз словить race condition при обновлении прав, один раз выкатить новый эндпоинт мимо общего декоратора — и утечка случилась.
У нас так и вышло. Разработчик добавил новый способ «пересобирать контекст» для длинных сессий: пользователь листает диалог, а мы дособираем ему релевантные куски. Функция читала чанки напрямую по document_id, минуя основной retriever. Права там никто не проверял, потому что «эта функция вызывается только после retriever, там уже проверено». Классика. Пока писали, так и было. Через полгода функцию начали дёргать из другого места — и проверка отвалилась.
Двухслойная защита в такой архитектуре — это удвоение количества мест, где надо не забыть. А значит, удвоение шансов забыть.
Row Level Security: пусть база сама решает, что кому отдавать
PostgreSQL умеет так, что даже голый SELECT * FROM chunks возвращает только те строки, которые доступны текущему «участнику». Это не про пользователей операционной системы, это про переменные сессии, которые вы сами устанавливаете при открытии соединения.
Схема стала такой. На старте каждого запроса FastAPI получает JWT, извлекает user_id и department_ids и первым делом в транзакции выполняет:
SET LOCAL app.user_id = '5721';
SET LOCAL app.departments = '{12,44,88}';
SET LOCAL app.clearance = 'standard';
На таблице chunks включён RLS с политикой:
CREATE POLICY chunks_visibility ON chunks
FOR SELECT USING (
document_id IN (
SELECT id FROM documents d
WHERE d.classification <= current_setting('app.clearance')::text
AND (
d.owner_id = current_setting('app.user_id')::int
OR d.department_id = ANY(
string_to_array(
trim(both '{}' from current_setting('app.departments')),
','
)::int[]
)
OR d.visibility = 'public'
)
)
);
Тот самый «пересборщик контекста», который ходил в chunks напрямую, теперь физически не может достать чанк из документа с грифом «restricted», если у пользователя нет соответствующего clearance. База просто вернёт пустой набор. Ошибку в коде это не исправит — но и утечкой она не станет.
Именно этот принцип, кстати, определяет, что такое эффективная веб-разработка инфраструктуры под код и ИИ-агентов: гарантию давать должен слой, который трогают реже всего. Код меняется каждую неделю. Политика на таблице — раз в квартал.
Что сломалось: pgvector, индекс и время поиска
Красиво в теории. На практике первый же ночной прогон показал: ANN по HNSW-индексу вместо привычных сорока с чем-то миллисекунд стал занимать сотни. Разница ощутимая. Прогнали ещё раз — не показалось.
Причина известная и обидная. HNSW и IVFFlat ничего не знают про RLS-политики. Индекс возвращает top-k ближайших векторов, а потом планировщик фильтрует их через политику. Если политика отсекает большую часть результатов, планировщик увеличивает k и повторяет — и так пока не наберётся сколько просили. На горячих документах это дорого.
Первая попытка починить была наивной: подкрутили hnsw.ef_search побольше, надеясь, что «ну возьмём с запасом, отфильтруется». Стало ещё хуже — латентность поплыла, а на кросс-отдельных запросах база иногда честно перебирала половину индекса. Пришлось откатить и подумать ещё раз.
Решили в два шага. Во-первых, вынесли часть предикатов в денормализованные поля прямо на chunks: chunks.department_id, chunks.classification, chunks.visibility. Их заполняет триггер при вставке и обновлении. Политика упростилась — подзапрос к documents для большинства случаев уже не нужен:
CREATE POLICY chunks_visibility ON chunks
FOR SELECT USING (
classification <= current_setting('app.clearance')::text
AND (
visibility = 'public'
OR department_id = ANY(...)
OR owner_id = current_setting('app.user_id')::int
)
);
Во-вторых, для частой операции — «поиск по документам моего отдела» — сделали partial-индексы pgvector по конкретным department_id. Планировщик научился их выбирать при наличии соответствующего фильтра. Латентность вернулась примерно к исходной. Для запросов через границу отделов чуть выше, но для B2B-ассистента, который и так думает несколько секунд, разница незаметна.
Ловушка коннекшн-пула: не забудьте про SET LOCAL
Первая версия использовала SET, а не SET LOCAL. И это было очень плохо. Между запросами соединение возвращалось в пул с предыдущими значениями app.user_id и app.clearance. Следующий запрос от другого пользователя мог унаследовать чужой контекст — и наоборот. Мы этого не увидели в тестах, потому что каждый тест поднимал соединение с нуля.
Спас код-ревью. Коллега посмотрел на PR и спросил: «А что будет, если два запроса подряд от разных юзеров попадут в одно соединение?» Я секунд десять молча смотрел в экран. С тех пор в проекте два правила, оба зафиксированы в pre-commit-хуке.
Первое: любой запрос к RLS-защищённой таблице открывается через контекстный менеджер with rls_session(user), который делает BEGIN, устанавливает SET LOCAL, отдаёт соединение и в finally — COMMIT или ROLLBACK. Никаких голых session.execute() мимо контекста.
Второе: pytest-фикстура assert_no_rls_leak в конце каждого теста дёргает current_setting('app.user_id', true) — если там что-то есть, тест падает. Это единственный способ поймать забытый SET без LOCAL в CI.
Аудит: кто и что пытался достать
Отдельная политика на таблицу audit_chunk_access пишет в лог факт возврата чанка с классификацией выше public — с user_id, document_id, вектором запроса и временем. Триггер на SELECT в PostgreSQL напрямую не повесить, но мы получили тот же эффект через SECURITY DEFINER-функцию: она обёрнута вокруг pgvector-поиска и вызывается из приложения вместо прямого SELECT.
Первую неделю просмотр логов делали руками, я просто открывал утром запрос и глазами пробегал глазами по строкам. На вторую натравили простой SQL: «пользователи, которые за час запросили подозрительно много документов с классификацией confidential». Раз в сутки — отчёт в почту безопасника.
Уже поймали одного менеджера, который через свободное поле «уточните» пытался вытянуть коммерческие условия сделок из соседнего департамента. Не вытянул — RLS отсёк на уровне базы. Но попытку зафиксировали, и с ним поговорили отдельно.
Что это дало на практике
За месяц после переезда — ни одного инцидента с утечкой через ИИ-агента. До этого таких историй за квартал набралось несколько, включая ту пятничную с премиями. Время ответа RAG выросло на считанные миллисекунды, заказчик изменений не заметил вовсе. А количество мест в коде, где надо «не забыть проверить права», схлопнулось до одной точки — той самой, где мы делаем SET LOCAL на входе в запрос.
Побочный эффект оказался приятным. RLS дал механизм мульти-тенантности «из коробки». Когда пришёл следующий клиент с похожей задачей — производственная компания с жёсткой моделью прав — мы просто добавили app.tenant_id в политики и подняли новое пространство памяти внутри той же базы. Раньше пришлось бы поднимать отдельный экземпляр или переписывать половину кода на явную фильтрацию.
Ещё один плюс, о котором я не думал заранее. Когда ИИ-агент на компьютере пользователя ходит в наш API через MCP или обычный REST, ему не надо доверять никаких «сервисных» токенов с расширенными правами. Токен пользователя — единственный источник контекста; агент физически видит ровно то же, что видит человек. Это резко упрощает разговоры с безопасниками заказчика: не нужно объяснять, почему у ИИ-агента более широкий доступ, чем у сотрудника.
Что я бы сделал иначе, если бы начинал сегодня
Сразу заложил бы RLS на этапе схемы, а не прикручивал за неделю после инцидента. Стоимость внесения — часы. Стоимость невнесения — от репутации до 152-ФЗ, если данные ушли не туда. Разница огромная, а инженерной работы примерно одинаково.
Второе — денормализация полей доступа на chunks с самого начала. Наступать на грабли производительности после катастрофы вдвое неприятнее, чем один раз при проектировании.
И третье, философское. Когда мы внедряем ИИ-агента в компанию, соблазн большой — сделать ему «служебную» учётку, которая видит всё, а фильтрацию навесить сверху в коде агента или в промпте. Не делайте так. Права доступа — это свойство данных, а не свойство функции, которая их читает. Чем ближе проверка живёт к строке в таблице, тем меньше шансов, что кто-то её обойдёт по невнимательности. ИИ-агент, который смотрит на корпоративную память через RAG или дёргает ИИ-агент API поверх неё, — это ещё один читатель этой памяти. И, как любому читателю, ему стоит показывать только то, что положено конкретному пользователю в конкретной сессии. База умеет это лучше вашего кода. Пусть делает.
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




