Представьте поисковую строку внутри корпоративного чата с документами. Менеджер по продажам вводит вопрос, а над результатами видит аккуратную строчку: «Найдено материалов: 14». Карточек на экране три, остальные одиннадцать интерфейс скрыл. Но число уже рассказало, что по теме есть ещё одиннадцать документов, которых он не видит. Возможно, это положение о премировании, возможно, переговорная позиция юристов. Сам текст не утёк, однако факт существования, объём и даже примерная тематика уже ушли за пределы отдела.
Эта мелочь хорошо показывает суть задачи. Разграничение доступа в RAG нельзя сделать косметикой на последнем шаге. Если скрывать чужое только при отрисовке, чужое остаётся в счётчиках, подсказках, цитатах, кэше и в самом тексте, который ушёл в модель. Права сотрудника должны действовать с первого шага запроса и до последнего символа ответа.
Вход в чат и право на документ — разные вещи
Первая ошибка проектирования — считать, что раз человек вошёл в систему, то вопрос доступа решён. Вход отвечает на вопрос «кто это». Полномочия на документы отвечают на другой вопрос: «что именно этому человеку можно читать, и на каком основании». Это два разных решения, и принимать их лучше в разных местах.
Единый вход (SSO, логин и пароль, корпоративный каталог) выдаёт личность. А вот список групп, отделов и ролей — отдельные данные, которые должны проверяться сервером при каждом запросе. Браузер, мобильное приложение или внешний интерфейс могут присылать только то, что нельзя подделать безнаказанно: сессионный токен. Всё остальное — отдел, группа, идентификатор арендатора — сервер восстанавливает сам по токену из своего источника правды.
Разница становится ощутимой на примере. Если запрос приходит в виде {"question": "...", "tenant_id": "sales"} и бэкенд честно подставляет sales в фильтр, то любой, кто умеет открыть вкладку сети в браузере, поменяет значение на legal. Система при этом работает идеально по собственной логике: фильтр применён, просто применён к чужому значению. Поэтому правило простое: поле группы в запросе от клиента либо игнорируется, либо сверяется со списком групп, который сервер определил по сессии.
Один вопрос, два сотрудника
Чтобы дальше рассуждать предметно, возьмём условный пример. Есть два сотрудника: Анна из отдела продаж и Борис из юридического отдела. Корпус документов небольшой, и в нём есть такие материалы:
- общий регламент отпусков — доступен всем;
- шаблон коммерческого предложения — продажи;
- внутренние условия скидок для ключевых клиентов — продажи, руководство;
- заключение юристов по спорному договору — только юридический отдел;
- положение о премировании — HR и руководство.
Оба сотрудника задают один и тот же вопрос: «Какие скидки мы можем дать клиенту, который просит пересмотреть договор?». Вопрос намеренно лежит на стыке: ему подходят и условия скидок, и юридическое заключение.
| Этап | Анна (продажи) | Борис (юристы) |
|---|---|---|
| Кандидаты из поиска | Условия скидок, шаблон КП, общий регламент | Заключение юристов, общий регламент |
| Контекст для модели | Только фрагменты из этих документов | Только фрагменты из его документов |
| Цитаты в ответе | Ссылки на условия скидок | Ссылки на заключение |
| Счётчик результатов | Считает только доступное Анне | Считает только доступное Борису |
Это и есть критерий приёмки всей затеи: два человека, один вопрос, два разных набора доказательств, и ни один не знает о существовании чужого набора. Ответы могут различаться даже по смыслу: Анна получит рекомендацию в пределах скидочной политики, а Борис — юридическую оценку. Это нормально. Ненормально, если ответ Анны содержит оговорку «по мнению юристов, положение спорное», потому что заключение юристов попало в контекст, хотя ей его показывать нельзя.
Путь запроса: где по дороге можно потерять права
У RAG-запроса несколько стадий, и на каждой есть соблазн «поправить потом». Пройдём по ним по порядку.
Выбор кандидатов
Векторный поиск возвращает ближайшие по смыслу фрагменты. Если искать по всей коллекции, а права применять после, возникают две проблемы. Во-первых, из топ-10 может отсеяться восемь чужих фрагментов, и пользователь получит почти пустой контекст при том, что доступные документы существовали, просто занимали одиннадцатое–тридцатое места. Во-вторых, пока чужие фрагменты вообще участвуют в выборке, они влияют на счётчики, порядок и всё, что считается по общему набору.
Поэтому ограничение должно входить в сам поисковый запрос. В векторной базе Qdrant условия фильтра проверяют поля payload, а условия в must объединяются как AND (документация Qdrant: Filtering). Для нашей задачи это значит, что у каждого фрагмента в payload хранится поле с группами, которым он открыт, а в запросе обязательным условием идёт принадлежность группы пользователя этому полю. Тогда поиск работает только по разрешённому подмножеству, и топ-10 заполняется доступными фрагментами.
Фильтр группы в payload
Документация по мультиарендности в Qdrant показывает точки разных арендаторов с идентификатором группы в payload и применение фильтра группы при запросе (Qdrant: Multitenancy). Этот подход хорошо ложится на наш случай: один корпус, у каждой точки есть метка принадлежности, запрос всегда несёт фильтр.
Важна оговорка, которая следует уже из логики, а не из документации: payload-фильтр — это механизм отбора, а не система авторизации. Он ничего не знает о том, кто спрашивает. Он честно применит то значение, которое ему передали. Вся ответственность за то, чтобы значение было правильным, лежит на вашем слое, стоящем перед базой.
Схематично рабочий вариант выглядит так: фрагмент документа с условиями скидок хранится с полем групп ["sales", "management"], а запрос Анны строится из условий «поле групп содержит одну из её групп». Если у человека несколько групп, условие должно быть «любая из», а не «все». Для отдельных документов с исключениями удобнее ввести явное поле запрета или хранить список разрешённых групп целиком, а не пытаться выводить права из названия папки.
Опасность tenant_id из браузера
Вернёмся к подделке значения. Типичный соблазн — передавать идентификатор арендатора или группы прямо из фронтенда: так проще писать код, и всё работает в демонстрации. Но тогда граница доступа проходит по полю, которое пользователь контролирует. Правильная схема обратная: клиент отправляет сессию и текст вопроса; сервер по сессии находит пользователя, по пользователю — его актуальные группы и собирает фильтр сам. Любые поля, присланные клиентом для фильтрации, либо отбрасываются, либо проверяются на вхождение в серверный список.
Полезный тест: возьмите запрос Анны, вручную подставьте в него группу юристов и убедитесь, что результат остаётся прежним или приходит отказ. Если выдача изменилась — граница проходит не там, где вы думали.
Сбор контекста
Даже если поиск ограничен, контекст иногда достраивается: соседние фрагменты того же документа, заголовки родительских разделов, связанные документы по ссылкам, краткие аннотации. Каждая такая достройка — новое обращение к хранилищу, и у неё должен быть свой фильтр. Типичная утечка выглядит безобидно: найден доступный фрагмент, а чтобы показать «соседний абзац», код берёт его по идентификатору документа без проверки прав. Если права на документ и на его части совпадают, проблем нет; если права выставлены на уровне разделов, соседний абзац может оказаться закрытым.
Практическое правило: любая операция, которая достаёт текст из хранилища, получает список групп пользователя как обязательный параметр. Функции без такого параметра не должны быть доступны коду, формирующему промпт.
Генерация
Модель не умеет хранить секреты по ролям. Всё, что попало в промпт, считается доступным автору запроса, потому что модель может пересказать, сократить или процитировать любой фрагмент контекста. Это основание для жёсткого правила: в промпт идёт только то, что пользователь вправе прочитать глазами. Просьба в системной инструкции «не раскрывай документы юристов» — не граница доступа, а пожелание, которое можно обойти формулировкой вопроса.
Отсюда же следует вывод про общий контекст между пользователями. Если вы держите длинный диалог и добавляете в него фрагменты разных людей, то права одного пользователя начинают смешиваться с правами другого. Контекст диалога должен жить в рамках одной сессии одного человека.
Кэш
Кэш — самое тихое место утечки. Предположим, система кэширует ответы по тексту вопроса, чтобы не гонять модель повторно. Борис задал вопрос про скидки, получил ответ со ссылкой на юридическое заключение, и ответ осел в кэше. Через десять минут Анна задаёт тот же вопрос, ключ совпал, и она получает ответ Бориса вместе с цитатой.
Решений два. Первое: включить в ключ кэша набор прав пользователя, то есть отсортированный список его групп или их хэш. Тогда у Анны и Бориса будут разные записи на один и тот же вопрос. Второе: кэшировать только промежуточные результаты, не зависящие от прав (например, эмбеддинг текста вопроса), а поиск и генерацию выполнять заново. Как устроить изоляцию кэша, в документации векторной базы не сказано, это отдельная задача вашего приложения, и проверять её нужно отдельным сценарием.
Тот же принцип относится к кэшам эмбеддингов, результатов поиска, сводок документов и подсказок автодополнения: если значение зависит от доступных документов, права должны входить в ключ, либо значение не должно кэшироваться.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Три проектных сценария, которые стоит проверить отдельно
Основной сценарий — «Анна и Борис задали один вопрос» — проверяет базовую фильтрацию. Но утечки чаще живут в краях. Три таких края полезно выписать как отдельные тесты.
Пустой поиск
Что видит пользователь, если по его правам ничего не нашлось? Самый опасный вариант — сообщение вроде «Есть материалы по этой теме, но у вас нет к ним доступа». Оно подтверждает существование закрытого документа. Для сотрудника, который не должен знать о проекте, это уже раскрытие. Безопасная формулировка не различает «нет совсем» и «нет доступного»: «По доступным вам материалам ответа найти не удалось». Если нужен путь запроса доступа, его лучше организовать через отдельную процедуру, а не через подсказки в чате.
Заодно проверьте поведение модели в пустом контексте. Если контекста нет, она не должна отвечать из общих знаний так, будто опирается на корпоративные документы. Пустой контекст — повод дать честный отказ.
Подсказки и счётчики
Вернёмся к исходной сцене со счётчиком. Любая агрегированная информация — число найденных документов, облако тегов, «похожие запросы», популярные вопросы по компании, автодополнение — строится на данных. Если эти данные собраны без учёта прав, закрытое проявляется косвенно. Подсказка «Как оформить увольнение по соглашению сторон» в строке ввода у сотрудника отдела продаж способна рассказать больше, чем осторожный ответ.
Проверка простая: выполните для каждого тестового пользователя все запросы, которые интерфейс отправляет автоматически, ещё до нажатия «Отправить». Для каждого — те же правила фильтрации, что и для основного поиска. Если подсказки формируются по журналу вопросов всех сотрудников, это тоже источник утечки: вопрос Бориса, содержащий название спорного контрагента, окажется в подсказках у Анны. Журнал вопросов лучше показывать только автору либо чистить от содержимого.
Кэш как отдельный тест
Для кэша придумайте последовательность из двух запросов: сначала Борис, затем Анна, с одинаковым текстом. Ответ Анны не должен содержать ни фрагментов, ни названий, ни цитат из закрытых для неё документов. Затем поменяйте порядок. Затем повторите после очистки кэша и сравните. Если результаты зависят от того, кто спросил первым, права в ключ не включены.
Когда права меняются
Права — не статичная таблица. Люди переходят между отделами, увольняются, получают временный доступ на проект. Система, которая проверяет доступ при индексации, а не при запросе, на этом ломается.
Смена отдела
Анна перешла из продаж в юридический отдел. В идеале сегодня она теряет доступ к материалам продаж, закрытым для юристов, и получает доступ к юридическим. Этого легко добиться, если права пользователя вычисляются на каждый запрос из актуального источника (каталога сотрудников). Если же группы сохранены в сессии на восемь часов, то до её истечения Анна живёт со старыми правами. Здесь проектное решение: либо короткие сессии, либо проверка актуальности групп при каждом обращении, либо принудительное завершение сессии при смене роли.
Другая сторона — права на сами документы. Если документ ранее был открыт продажам, а теперь закрыт, нужно обновить поле групп у всех его фрагментов в индексе. Забытый фрагмент с устаревшей меткой — и закрытое снова доступно. Поэтому при изменении прав документа обновление должно затрагивать все его точки, а не только карточку документа. По схожей причине стоит держать связь «документ — фрагменты» явной: тогда можно найти и переписать все записи одной операцией. Об обновлении содержимого документов в индексе мы говорили в материале про актуальность документов в RAG; для прав логика та же: источник правды один, индекс догоняет его.
Отозванные права
Отзыв — самый неприятный случай, потому что он требует действия немедленно. Увольняемому сотруднику закрывают вход, но если у него был долгоживущий токен, который сервер не сверяет с каталогом, он продолжит задавать вопросы. Проверка актуальности пользователя перед поиском стоит дёшево, а пропущенный отзыв — дорого.
Уже сохранённые ответы
Отдельный слой — всё, что уже было выдано. История чатов, сохранённые ответы, экспорт в заметки, пересланные письма. Если Борис потерял доступ к юридическим материалам, его история диалогов всё ещё содержит цитаты из них. Решение зависит от политики компании, и универсального ответа нет. Варианты такие:
- хранить историю как есть, считая выданное прошлым фактом, и ограничивать только новые запросы;
- при отзыве прав помечать ответы с цитатами из закрытых документов и скрывать их при открытии истории;
- хранить в истории только ссылки на источники, а не их текст, и при открытии заново проверять доступ.
Третий вариант самый строгий и затратный, зато согласуется с принципом «права проверяются при чтении». Выбор нужно сделать осознанно и записать, а не получить по умолчанию.
Прямая ссылка на оригинал
Допустим, чат отработал идеально: Анне показывается только то, что ей можно. В ответе есть цитата и ссылка «открыть документ». Теперь Анна копирует адрес из ответа Бориса, который тот переслал ей в мессенджере, или просто угадывает идентификатор, увеличив число в адресе на единицу. Открывается ли оригинал?
Если файл отдаётся по прямому адресу без проверки прав, то вся фильтрация в чате превращается в декорацию. Проверка должна стоять на самом хранилище оригиналов, независимо от видимости в чате: каждое обращение к файлу идёт через слой, который по сессии определяет пользователя, смотрит его права на документ и только тогда отдаёт содержимое. Случай с предсказуемыми идентификаторами особенно показателен: «ссылки, которые никто не знает» не защита, а надежда.
Практичная проверка: возьмите ссылку на закрытый документ, откройте её в новом профиле браузера под пользователем без прав и без сессии. Ожидаемый результат — отказ в обоих случаях. Затем повторите то же для вложений, превью, скачивания в других форматах и миниатюр: у них часто отдельные адреса и отдельные обработчики, которые забывают закрыть.
Если вложения лежат в файловом хранилище, например в Nextcloud, права на них стоит согласовать с правами в индексе: иначе чат скажет «нельзя», а файловое хранилище скажет «пожалуйста». Одна из самых частых причин такого расхождения — две системы, которые обновляют права независимо друг от друга.
Где всё это разместить
Архитектура из нескольких компонентов — приложение, векторная база, хранилище файлов, кэш, служба авторизации — удобно живёт на одной управляемой вами машине, пока нагрузка небольшая. Для такой связки подходит VPS с root-доступом: вы сами настраиваете, какие порты доступны снаружи, кто может обращаться к базе, и держите кэш и поиск в закрытой внутренней сети. Это важно именно для нашей темы: векторная база с корпоративными фрагментами не должна быть доступна из интернета напрямую, обращаться к ней должен только ваш слой с проверкой прав.
MAATRIX предлагает VPS на NVMe и процессорах AMD EPYC с root-доступом; в публичном предложении указаны локации UK, US, RU и FR, а в каталоге есть n8n и Nextcloud с установкой (точное наличие и ОС уточняйте в форме заказа). Для небольшого пилота с парой отделов можно начать с тарифа Uab (2 vCPU, 4 ГБ RAM, 80 ГБ NVMe) или Heka (4 vCPU, 8 ГБ, 160 ГБ), если база и приложение живут вместе. Эти ресурсы — исходная точка, а не обещание ёмкости: сколько одновременных сотрудников и документов потянет конкретная связка, покажет замер на ваших данных. Так же ни тариф, ни расположение сервера сами по себе не заменяют продуманную модель прав: изоляция строится в приложении.
Сборка проверки: что должно пройти
Итоговую проверку удобно держать как набор сценариев, которые можно прогонять после каждого изменения системы. Каждый сценарий отвечает на один вопрос.
- Анна и Борис задают один вопрос: у каждого в ответе, цитатах и счётчике только свои документы.
- В запрос от клиента подставлена чужая группа: выдача не меняется, либо приходит отказ.
- Поиск не вернул доступных документов: ответ не намекает на существование закрытых.
- Строка ввода и подсказки: у пользователя без прав не появляются названия и темы чужих материалов.
- Борис спрашивает первым, затем Анна: ответ Анны не зависит от кэша Бориса.
- Анну переводят в другой отдел: следующий же запрос использует новые права.
- У документа сменили группы: обновились все его фрагменты в индексе, не только карточка.
- У сотрудника отозван доступ: старый токен больше не даёт ответов.
- Прямая ссылка на оригинал, превью и вложение открываются только при наличии прав.
Девять сценариев — немного, и все они проверяются руками за час на тестовом наборе из пяти-шести документов. Зато каждый закрывает отдельный путь, по которому закрытое могло бы просочиться: через поиск, подсказки, кэш, смену прав или адрес файла.
Что останется на экране, когда всё сделано правильно
Вернёмся к счётчику из начала. В правильной системе он показывает «Найдено: 3», и эти три — всё, что для данного человека существует. Для Анны мир документов состоит из того, что ей открыто, и нигде в системе нет дырки, через которую можно было бы заметить пустоту на месте закрытого.
Это и есть критерий, по которому стоит оценивать готовность: не «чужая карточка не отображается», а «чужие данные отсутствуют на всём пути ответа» — в кандидатах поиска, в контексте для модели, в тексте и цитатах, в кэше, в подсказках, в истории и в адресе файла. Если хотя бы на одном этапе они присутствуют и скрываются только в конце, это не разграничение доступа, а ширма, которая держится до первого внимательного пользователя.