Представьте поиск по внутренней базе знаний. Карточку закрытого документа он честно не показывает. Но в панели фильтров рядом с «Общими регламентами» вдруг стоит строка «Зарплатные ведомости — 14», а поле подсказок на слове «уволь» предлагает заголовок приказа. Документ скрыт, а факт его существования и даже его размер уже вышли наружу. Скрыть карточку — значит ограничить только один из путей ответа. Права должны действовать там, где поисковик считает, группирует и подсказывает, то есть до формирования ответа, а не после.
Разберём это на небольшом примере. Он условный, но достаточно близкий к реальности, чтобы на нём видны все подводные камни: два раздела, два пользователя, один индекс на вашем VPS.
Два раздела и два человека, чьи права не пересекаются
Пусть в базе знаний есть раздел support с инструкциями для службы поддержки и раздел finance с регламентами бухгалтерии. Анна работает в поддержке и видит только support. Борис — в бухгалтерии и видит только finance. Пересечения нет намеренно: так любая утечка заметна сразу. Если Анна увидит хоть что-то из finance, это ошибка, и не нужно спорить, насколько она существенна.
Каждый документ в индексе получает явное поле доступа. Для простоты назовём его section, а для документов с несколькими разделами можно использовать массив allowed_groups. Важное правило: поле создаётся из разметки прав в основной системе, а не угадывается поиском. Токен ограничивает выдачу по тому, что записано в документе, и не исправит неверную разметку. Если бухгалтерский регламент по ошибке получил section = support, Анна его найдёт, и никакая криптография тут не поможет.
Пример документа в индексе kb выглядит так:
{
"id": "fin-0042",
"title": "Порядок согласования выплат",
"body": "...",
"section": "finance",
"allowed_groups": ["finance"]
}Заметьте, что идентификатор и заголовок не должны содержать ничего, что нельзя показать человеку без прав, если заголовок вдруг утечёт. Хотя цель как раз в том, чтобы он не утёк, строить систему на предположении об идеальной фильтрации разумно только в сочетании с аккуратными данными.
Где живёт решение о правах: backend выдаёт, браузер предъявляет
Первая мысль, которая приходит многим: пусть браузер сам добавляет к запросу фильтр section = support. Это не защита. Пользователь открывает инструменты разработчика, правит запрос и убирает условие. Фильтр, который можно отредактировать на стороне клиента, — это удобство интерфейса, а не граница доступа.
В Meilisearch для этого есть tenant tokens. Согласно документации по безопасности, такой токен содержит search rules с фильтрами и ограничивает результаты поиска пользователя. Генерирует его ваш backend из API key, а базовые разрешения остаются задачей самого ключа. То есть последовательность такая: пользователь входит в основную систему, backend выясняет его права, подписывает токен с нужным фильтром и отдаёт токен браузеру. Дальше браузер ходит в поиск только с этим токеном и изменить встроенный в него фильтр не может: подпись перестанет сходиться.
Откуда backend берёт права? Из того же места, где живёт авторизация самой базы знаний: из таблицы ролей, из каталога пользователей, из групп. Поиск не источник истины о правах, он их копия, и об этом ещё пойдёт речь, когда дойдём до отзыва доступа.
Какой ключ подписывает токен
Токен не хранится на стороне Meilisearch. По спецификации tenant tokens, его подписывает API key с действием search, а не master key, и права токена не могут быть шире прав подписавшего ключа. Поэтому сначала создаём узкий родительский ключ. По руководству по управлению ключами, это запрос POST /keys с actions равным ["search"], конкретным UID индекса в indexes и сроком expiresAt. Админские ключи остаются только на backend. Там же сказано, что actions, indexes и expiresAt у созданного ключа изменить нельзя: понадобились другие права — создаётся новый ключ.
Практический вывод: не делайте родительский ключ бессрочным «чтобы не возиться». Срок у него есть именно для того, чтобы ротация была привычным действием, а не авралом. Порядок ротации в руководстве простой: создать новый ключ, переключить приложение, проверить, удалить старый.
Токен для Анны: короткий payload, короткая жизнь
Теперь собственно выдача. В актуальном JS-примере руководства по multitenancy функция generateTenantToken импортируется из meilisearch/token и вызывается с параметрами apiKey, apiKeyUid, searchRules и expiresAt. Правила — объект, где ключи — имена индексов, а значение содержит filter. Срок передаётся объектом Date.
import { generateTenantToken } from 'meilisearch/token'
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
const token = await generateTenantToken({
apiKey: SEARCH_PARENT_KEY,
apiKeyUid: SEARCH_PARENT_KEY_UID,
searchRules: {
kb: { filter: 'allowed_groups = support' }
},
expiresAt
})Здесь два осознанных решения. Первое — срок. В документации пример с конкретной датой иллюстративный, и копировать прошедшую дату нельзя: токен окажется просроченным в момент выдачи. Считайте срок от текущего времени, и пусть он будет коротким: в примере пятнадцать минут. Второе — фильтр строится на backend из прав, найденных в основной системе, а не из чего-либо, пришедшего от клиента.
Одна техническая ловушка, о которой предупреждает руководство по составу payload: внутри полезной нагрузки срок exp задаётся в секундах Unix, а в SDK передаётся Date. Если вы собираете токен вручную и подставите миллисекунды, получите срок где-то в далёком будущем. Поэтому руководство рекомендует SDK вместо ручной криптографии, и мы не будем писать собственную JWT-обвязку: в рабочем коде достаточно того, что показано выше.
Чего токен не умеет
Токен ограничивает search endpoint. Административные операции — индексирование, настройки, управление ключами — он не закрывает и не открывает: это сказано прямо в документации безопасности. Значит, ключ с правом записи в индекс по-прежнему обязан сидеть на backend, а браузеру отдаётся только токен. Если во фронтенд случайно попадёт admin key или широкий search key, tenant token перестанет иметь смысл: клиент просто возьмёт ключ и будет искать без ограничений.
Есть и обратная опасность, которую легко упустить. По спецификации, пустое правило для разрешённого индекса не ограничивает документы. Если из-за ошибки в коде у пользователя без групп получится пустая строка фильтра, он увидит весь индекс. Поэтому на backend нужен жёсткий порядок: нет прав — токен не выдаётся вовсе, а не выдаётся «пустой». И отдельный тест на этот случай.
Поле доступа должно быть фильтруемым
Фильтр по полю работает только если оно объявлено. Документация по фильтрам говорит, что перед использованием filter поле нужно добавить в filterableAttributes, а по умолчанию список пуст. Настройка выполняется административным ключом:
PUT /indexes/kb/settings/filterable-attributes
["allowed_groups", "section"]Важно помнить цену: изменение filterableAttributes запускает переиндексацию, и её длительность зависит от объёма и сложности данных. Это не параметр запроса, а настройка индекса, которую делают заранее, до первого пользователя. На небольшой базе это секунды или минуты, на большой — заметный промежуток, в котором лучше не вносить других изменений. Поэтому разумно продумать список полей доступа в начале: добавлять их по одному под каждое новое требование значит переиндексировать снова и снова.
Там же отмечено, что фильтры сравнения могут быть затратнее простого равенства, а нужные возможности можно включать гранулярными настройками. Для прав это хорошая новость: нам нужны именно равенство и принадлежность группе, сравнения «больше-меньше» по полю доступа не требуются.
Где всё это работает? Meilisearch на вашем сервере держит индекс, а переиндексация и поиск потребляют процессор, память и диск. Для небольшой базы знаний хватит скромного тарифа, например Ba или Uab, но это лишь отправная точка для пробы, а не обещание ёмкости: реальную нагрузку покажет замер на ваших документах. NVMe-диск полезен именно при переиндексации, а root-доступ позволяет спокойно держать отдельный тестовый экземпляр рядом с боевым. Установку самого Meilisearch подробно разбирает статья как установить и настроить Meilisearch на VPS.
Порядок применения: фильтр токена идёт первым
Важное свойство из спецификации: фильтр из searchRules применяется до параметров поискового запроса. Это значит, что клиент может добавить свой filter, но он сочетается с правилом токена, а не заменяет его. Анна, допустим, подставит filter: "allowed_groups = finance". Пересечение «только support» и «только finance» даёт пустоту. Утечки нет, а ответ пуст — и это именно тот результат, который нужно увидеть в тесте.
По справке по POST-поиску, запрос принимает q, filter и facets, а фильтр — это строка или массив с логикой AND и OR. Заметьте: OR, добавленный клиентом, не расширит права токена, потому что токенный фильтр накладывается поверх. Но проверять это всё равно нужно руками, а не принимать на веру, и дальше мы так и сделаем.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Четыре обходных пути, которые стоит прогнать руками
Вернёмся к сцене из начала. Карточки скрыты, но информация может утекать по боковым каналам. Составим короткую приёмочную таблицу для двух наших пользователей. Для каждого запроса используется токен Анны, а в базе есть документы finance.
| Что пробуем | Ожидаемый результат |
|---|---|
Пустой запрос q: "" | Только документы support, ни одного finance |
| Запрос по слову из заголовка закрытого документа | Нет попаданий в finance |
facets: ["section"] | В facetDistribution нет значения finance |
Клиентский filter: section = finance | Пустой список |
| Клиентский OR с finance | Только документы support |
Пустой запрос
Пустой q возвращает документы без ранжирования по тексту. Именно он чаще всего забывается в проверках, потому что «никто же не ищет пустоту». Но интерфейс, показывающий «последние документы» или каталог раздела, делает именно это. Если фильтр держит пустой запрос, это сильное доказательство, что он применяется на уровне выборки, а не на уровне текстового совпадения.
Фасеты и счётчики
Здесь чаще всего и случается утечка из вступительной сцены. Фасеты считают число совпадений по значениям поля и, по документации, используют filterableAttributes, а ответ содержит facetDistribution. Эти счётчики относятся к найденным документам, то есть уже к отфильтрованному набору. Если токен работает правильно, Анна не увидит значения finance и его числа. Проверьте: запросите фасет по section и по allowed_groups и убедитесь, что чужих значений нет.
Отдельно про общее число результатов. Обычный ответ содержит hits и estimatedTotalHits, а постраничный режим page и hitsPerPage использует totalHits. Оценочный счётчик нельзя считать точным, и в интерфейсе не стоит подавать его как точное «найдено ровно N документов». С точки зрения прав главное другое: число считается уже внутри разрешённого набора, поэтому по нему нельзя узнать, сколько документов спрятано. Но если вы посчитаете общее число документов в обход токена, например в служебном дашборде на том же ключе, вы покажете именно эту утечку. Проверьте, какой ключ использует каждая панель вашей системы.
Подсказки и автодополнение
Подсказки почти всегда реализуют обычным поиском с маленьким лимитом, а значит, они проходят через тот же токен. Но если подсказки строятся отдельным механизмом — кэшем популярных запросов, словарём заголовков, отдельным индексом — токен их не защищает. Простое правило: любой источник подсказок должен либо идти через поиск с токеном пользователя, либо содержать только данные, безопасные для всех. Кэш «популярных запросов» тоже опасен: если Борис часто ищет «премия руководителей», эта фраза не должна появиться у Анны в списке.
Подмена фильтра на клиенте
Последний пункт таблицы — проверка того, с чего начался разговор. Откройте инструменты разработчика, найдите поисковый запрос, измените filter и отправьте снова. Токен Анны должен продолжать работать в своих рамках. Затем попробуйте изменить сам токен, например заменить символ в середине. Подпись не сойдётся, и запрос будет отклонён. Если вместо отказа вы получаете данные, значит, где-то в цепочке стоит не токен, а широкий ключ.
Права меняются, а токен ещё живёт
Теперь самое неудобное. Анну перевели из поддержки в отдел контроля качества, и доступ к support убрали в основной системе. Но у неё в браузере лежит токен, выданный десять минут назад со сроком пятнадцать минут. Что произойдёт?
Токен продолжит работать до истечения. Спецификация прямо не предусматривает индивидуального отзыва tenant token: токены не хранятся в Meilisearch, и отозвать конкретный нельзя. Единственные рычаги — родительский ключ: удаление подписавшего API key отзывает все его токены разом, а истечение родительского ключа также делает токены недействительными. Смена master key инвалидирует API keys и все производные токены, но это радикальная мера для аварии, а не для будней.
Отсюда вытекает проектное решение. Нельзя полагаться на отзыв конкретного токена, его не существует. Вместо этого вы управляете окном риска:
- короткий срок токена: окно после отзыва права не больше этого срока;
- токен не выдаётся повторно после смены прав, а клиент получает новый только через backend, который заново читает права;
- для экстренного случая — «сотрудника уволили сейчас» — нужна процедура с запасным родительским ключом.
Как выглядит такая процедура? Токены подписаны ключом A. Вы создаёте ключ B, переключаете backend на выдачу токенов от B и удаляете ключ A. Запросы с токенами от A после этого перестанут работать, как описано в руководстве по ключам: после удаления они отклоняются. Но удар придётся по всем пользователям, подписанным ключом A, и им придётся получить токены заново. Это нормально для чрезвычайной ситуации, и ненормально для ежедневного перевода сотрудников. Поэтому не смешивайте два уровня: индивидуальный отзыв прав решается сроком токена, а групповой отзыв — заменой родительского ключа. Если разделить пользователей на несколько родительских ключей, например по отделам, удар от удаления одного ключа будет локальным. Но это усложнение имеет смысл только при реальной потребности, и вводить его заранее не обязательно.
Отставание индекса
Есть вторая, независимая задержка. Допустим, документ из support перенесли в finance, или закрыли для всех. В основной системе изменение уже действует, но индекс обновляется асинхронно: задача на обновление документа стоит в очереди и ещё не выполнена. В этот промежуток поиск может отдавать документ с прежней разметкой доступа, то есть с устаревшим полем allowed_groups.
Токен тут бессилен: он честно применяет фильтр к тому, что записано в индексе. Значит, обновление полей доступа должно быть приоритетной операцией. На практике это означает три вещи. Изменение прав в основной системе должно сразу порождать обновление документа в индексе. Вы должны уметь дождаться завершения задачи индексирования и убедиться, что она выполнена. И, наконец, нужна периодическая сверка: сравнение прав в основной системе и в индексе по выборке документов. Если расхождение найдено, это сигнал, что цепочка обновлений где-то теряется.
Есть и защитный ход, который снимает проблему в самом чувствительном случае. Закрытие документа можно считать опасной операцией и сначала применять к индексу, а уже потом подтверждать пользователю. Для «сужения» прав порядок «сначала поиск, потом остальное» безопаснее, чем наоборот. Для «расширения» порядок не критичен: если документ откроется в поиске на минуту позже, никто не пострадает.
Последняя линия: исходный документ
Допустим, вся предыдущая цепочка идеальна. Анна не видит финансовые документы ни в выдаче, ни в фасетах, ни в подсказках. Но у неё в руках оказалась прямая ссылка на документ, полученная в чате от коллеги или из старой закладки. Что будет, если открыть её?
Поиск — это лишь витрина, а не хранилище. Сам документ открывается через основную систему: wiki, портал, файловое хранилище. И именно она должна заново проверить право на каждое открытие. Поисковое ограничение защищает от обнаружения, но не от доступа по известному адресу. Это две разные задачи, и ни одна не заменяет другую.
Практическая проверка проста. Возьмите адрес закрытого документа и откройте его с учётной записью Анны. Ожидаемый ответ — отказ в доступе, причём без деталей: сообщение «нет прав» лучше, чем «документ существует, но вам недоступен». Если вместо отказа открывается страница, значит, права проверялись только на уровне поиска, и поиск оказался единственным замком. Тогда исправлять нужно основную систему, а не настройки Meilisearch.
Если вы храните сами файлы на том же VPS, например в Nextcloud, проверка прав на открытие остаётся функцией этого приложения, а не поискового сервера. Meilisearch не знает, как приложение выдаёт ссылки на файлы, и не может отозвать уже выданную публичную ссылку.
Сквозной прогон на тестовом сервере
Всё описанное удобно собрать в один сценарий на отдельном тестовом VPS, а не на боевом. Root-доступ позволяет поднять временный экземпляр, загрузить туда несколько десятков документов с разметкой support и finance и прогнать проверки. Конфигурацию тарифа подберите по своим данным: ресурсы тарифов Ba, Uab, Heka, Maat и Djed — это набор вариантов, а не гарантия числа документов или запросов, и точный выбор определяют замеры на вашей базе. Наличие образов и ОС в нужной локации уточняйте в форме заказа.
Порядок проверки такой:
- Создать индекс
kb, объявитьallowed_groupsиsectionвfilterableAttributes, дождаться завершения переиндексации. - Загрузить документы обоих разделов и одно «ловушечное» название с узнаваемым словом, которое есть только в
finance. - Создать родительский search-ключ с узким индексом и сроком.
- Выдать токены Анне и Борису с короткими сроками.
- Прогнать таблицу из раздела об обходных путях для обоих пользователей.
- Сменить права Анны в тестовой системе, сделать пробный запрос ещё действующим токеном и зафиксировать, что он работает до истечения.
- Дождаться истечения срока и убедиться, что запрос отклоняется.
- Изменить разметку документа и замерить, через какое время поиск начинает отражать новое значение.
- Открыть прямую ссылку на закрытый документ с учётной записью Анны.
Шаги шесть и восемь дают две цифры, которые вам нужно знать о собственной системе: максимальное окно действия токена после отзыва права и типичное отставание индекса. Они определяют, насколько вы вправе называть доступ «немедленно закрытым». Если окно в пятнадцать минут вас не устраивает, срок токена можно сократить, но тогда backend чаще обновляет токены, и нагрузка на него растёт. Это компромисс, который решается именно для вашей базы знаний.
Закрытый документ и обходной поиск
Заключительная проверка складывается из одного простого эксперимента. Возьмите самый чувствительный документ из finance, например регламент выплат. Анна пробует найти его всеми способами: по полному заголовку, по редкому слову из текста, по пустому запросу с листанием страниц, через фасет по разделу, через подсказки, через подмену клиентского фильтра и через изменённый токен. Ни один путь не должен показать документ, его заголовок или факт его существования. Затем она пробует открыть его по прямой ссылке в основной системе и получает отказ.
Когда оба условия выполнены, можно говорить, что документ закрыт. Не потому, что он пропал из выдачи, а потому, что поиск не знает для Анны о его существовании, а основная система не пускает её внутрь. Если проверка провалилась хотя бы в одном месте, это не мелочь для следующей итерации, а повод остановиться и найти, на каком отрезке пути права перестали действовать.