Глобальный поиск с учётом прав доступа без утечек данных
Как спроектировать глобальный поиск с учётом прав доступа: быстрое индексирование, строгие построчные проверки и отсутствие утечек.

Почему глобальный поиск может приводить к утечкам данных
Глобальный поиск обычно — это одно поле, которое просматривает всё в приложении: клиентов, тикеты, счета, документы, пользователей и всё остальное. Он часто даёт автодополнение и страницу быстрых результатов, чтобы пользователь мог сразу перейти к записи.
Утечка возникает, когда поиск возвращает то, о чём пользователь не должен знать. Даже если он не может открыть запись, одна строка — заголовок, имя человека, метка или фрагмент с подсветкой — может раскрыть конфиденциальную информацию.
Поиск кажется «только для чтения», поэтому команды недооценивают угрозу. Но он может выдать данные через заголовки результатов и превью, подсказки автозаполнения, общие счётчики результатов, фасеты вроде «Клиенты (5)» и даже через различия во времени ответа (быстро для одних запросов, медленнее для других).
Проблема обычно проявляется не в первый день. Сначала команды выпускают поиск при единственной роли или в тестовой базе, где все видят всё. По мере роста продукта добавляются роли (support vs sales, managers vs agents) и фичи вроде общих почтовых ящиков, приватных заметок, ограниченных клиентов и «только мои аккаунты». Если поиск всё ещё опирается на старые допущения, он начинает выдавать подсказки между командами или клиентами.
Обычно ошибку допускают, индексируя «всё» для скорости, а потом пытаясь отфильтровать результаты в приложении после выполнения запроса. Это уже поздно: поисковая машина решила, что подходит, и могла раскрыть ограниченные записи через подсказки, счётчики или частичные поля.
Представьте агента поддержки, который должен видеть тикеты только своих клиентов. Он вводит «Acme», и автодополнение показывает «Acme — юридическое обострение» или «уведомление о нарушении Acme». Даже если при клике появится «доступ запрещён», сам заголовок — это утечка.
Цель permission-aware глобального поиска проста в формулировке и сложна в реализации: быстро возвращать релевантные результаты, одновременно обеспечивая те же правила доступа, что и при открытии записи. Каждый запрос должен вести себя так, будто пользователь видит только свою часть данных, а интерфейс — не выдавать дополнительных подсказок (счётчиков, фрагментов и т.п.) за пределами этой части.
Что вы индексируете и что нужно защищать
Глобальный поиск кажется простым, потому что пользователь вводит слова и ждёт ответ. Но под капотом вы создаёте новую поверхность для утечек. Прежде чем выбрать индекс или функцию БД, чётко определите два момента: какие объекты вы ищете (сущности) и какие части этих объектов чувствительны.
Сущность — любая запись, которую кто-то может быстро найти. В бизнес-приложениях это обычно клиенты, тикеты поддержки, счета, заказы и файлы (или метаданные файлов). Это могут быть карточки людей (пользователи, агенты), внутренние заметки и системные объекты вроде интеграций или API-ключей. Если у записи есть имя, идентификатор или статус, который кто-то может ввести, она, скорее всего, попадёт в глобальный поиск.
Правила на уровне записи vs правила на уровне таблицы
Правила на уровне таблицы грубые: либо вы можете получить доступ ко всей таблице, либо нет. Пример: только финансы могут открывать страницу «Счета». Это просто, но ломается, когда разные люди должны видеть разные строки в одной таблице.
Правила на уровне записи решают видимость построчно. Пример: агент поддержки видит тикеты, назначенные его команде, а менеджер видит все тикеты в своём регионе. Ещё один общий сценарий — владение по арендаторам: в multi-tenant приложении пользователь видит записи, где customer_id = their_customer_id.
Именно эти построчные правила чаще всего приводят к утечкам. Если индекс возвращает совпадение до проверки доступа к строке, вы уже показали, что запись существует.
Что в практике значит «разрешено видеть»
«Разрешено» редко — это просто да/нет. Обычно это комбинация владения (создано мной, назначено мне), членства (моя команда, мой департамент, моя роль), области (мой регион, бизнес-единица, проект), состояния записи (опубликовано, неархивировано) и специальных случаев (VIP-клиенты, юридические удержания, ограничивающие теги).
Запишите эти правила в простом виде. Позже вы превратите их в модель данных и серверные проверки.
Решите, что безопасно показывать в превью результата
Результаты поиска часто содержат сниппет, а сниппеты могут пролить ценную информацию, даже если пользователь не может открыть запись.
Безопасный дефолт — показывать только минимальные, не чувствительные поля до подтверждения доступа: отображаемое имя или заголовок (иногда с маской), короткий идентификатор (номер заказа), общий статус (Open, Paid, Shipped), дату (создания или обновления) и общий ярлык сущности (Ticket, Invoice).
Конкретный пример: если кто-то ищет «Acme merger» и есть ограниченный тикет, вернуть «Ticket: Acme merger draft — Legal» уже утечка. Более безопасно показать «Ticket: Restricted» без сниппета или вообще не показывать результат, в зависимости от вашей политики.
Чёткие определения заранее упрощают последующие решения: что индексировать, как фильтровать и что вы готовы раскрывать.
Базовые требования для безопасного и быстрого поиска
Люди пользуются глобальным поиском, когда торопятся. Если он работает дольше секунды, им перестаёт доверять, и они возвращаются к ручной фильтрации. Но скорость — только половина задачи. Быстрый поиск, который выдаёт хоть одну строку с названием записи, именем клиента или темой тикета, хуже, чем отсутствие поиска.
Основное правило не обсуждается: применяйте права доступа в момент выполнения запроса, а не только в UI. Скрыть строку после её получения уже слишком поздно, потому что система коснулась данных, которые не должна была возвращать.
То же относится ко всему окружению поиска, не только к финальному списку результатов. Подсказки, топ-хиты, счётчики и даже поведение «нет результатов» могут выдать информацию. Автозаполнение, которое показывает «Acme Renewal Contract» человеку без прав, — утечка. Фасет с «12 подходящих счетов» — утечка, если пользователь может видеть только 3. Даже задержки во времени могут дать подсказку.
Безопасный глобальный поиск требует четырёх вещей:
- Корректность: каждый возвращённый элемент разрешён для этого пользователя и этого арендатора прямо сейчас.
- Скорость: результаты, подсказки и счётчики остаются быстрыми даже при большом объёме данных.
- Согласованность: при изменении прав (обновление роли, переназначение тикета) поведение поиска меняется быстро и предсказуемо.
- Аудируемость: вы можете объяснить, почему элемент был возвращён, и логировать активность поиска для расследований.
Полезный сдвиг в мышлении: рассматривайте поиск как ещё один data API, а не только как UI-фичу. Это значит, что те же правила доступа, что на страницах списков, должны применяться к построению индекса, выполнению запросов и всем связанным endpoint'ам (autocomplete, последние запросы, популярные запросы).
Три распространённых шаблона проектирования (и когда их применять)
Сделать поле поиска просто. Сделать permission-aware глобальный поиск сложнее, потому что индекс хочет возвращать результаты мгновенно, а приложение не должно выдавать запись, к которой нет доступа, даже косвенно.
Ниже три наиболее часто используемых подхода. Выбор зависит от сложности правил доступа и уровня риска.
Подход A: индексировать только «безопасные» поля, затем загружать и проверять права.
В индекс вы кладёте минимальный документ, например ID и не чувствительную метку, которую можно показывать всем, кто видит UI поиска. Когда пользователь кликает результат, приложение загружает полную запись из первичной БД и применяет реальные правила доступа.
Это снижает риск утечек, но делает поиск «тонким», потому что в результатах мало контекста. Нужна аккуратная формулировка в UI, чтобы «безопасная» метка не стала сама по себе секретом.
Подход B: хранить атрибуты прав в индексе и фильтровать там.
В документ индекса добавляются поля вроде tenant_id, team_id, owner_id, флаги ролей или project_id. Каждый запрос добавляет фильтры, соответствующие области видимости текущего пользователя.
Это даёт быстрые и богатые результаты и хорошее автодополнение, но работает только если правила доступа можно выразить фильтрами. Если разрешения зависят от сложной логики (например, «назначено ИЛИ дежурный на этой неделе ИЛИ участник инцидента»), поддерживать корректность становится трудно.
Подход C: гибрид. Грубое фильтрование в индексе, финальная проверка в базе.
Вы фильтруете в индексе по стабильным, широким атрибутам (tenant, workspace, customer), затем повторно проверяете права по маленькому набору кандидатов в первичной БД перед возвращением результатов.
Это часто самый безопасный путь для боевых приложений: индекс остаётся быстрым, а база — источником истины.
Как выбирать
Выбирайте A, когда нужен самый простой подход и можно жить с минимальными сниппетами. Выбирайте B, когда есть чёткие, почти статичные области видимости (multi-tenant, доступ по командам) и требуется очень быстрое автодополнение. Выбирайте C, когда много ролей, исключений или правил, меняющихся часто. Для данных с высоким риском (HR, финансы, медицина) предпочитайте C — «почти правильно» недопустимо.
Пошагово: спроектируйте индекс, который уважает права доступа
Начните с того, что запишите правила доступа так, как бы вы объясняли их новому коллеге. Избегайте «админ видит всё», если это не так на самом деле. Пропишите почему кто-то может видеть запись: «Агенты поддержки видят тикеты своего арендатора. Тимлиды видят тикеты своего подразделения. Только владелец и назначенный агент видят приватные заметки.» Если вы не можете объяснить причину видимости, вам будет сложно безопасно это закодировать.
Далее выберите стабильный идентификатор и определите минимальную схему документа поиска. Индекс не должен быть полной копией строки БД. Оставьте только то, что нужно для поиска и отображения в списке результатов: заголовок, статус и, возможно, короткий ненавязчивый сниппет. Чувствительные поля — за вторичным запросом с проверкой прав.
Затем решите, какие сигналы доступа можно быстро фильтровать. Это поля, которые ограничивают доступ и могут храниться в каждом индексированном документе: tenant_id, org_unit_id, несколько флагов видимости. Цель — чтобы каждый запрос мог применить фильтры до возврата результатов, включая автодополнение.
Практический рабочий процесс:
- Опишите правила видимости для каждой сущности (тикеты, клиенты, счета) простым языком.
- Создайте схему документа поиска с record_id и только безопасными полями, нужными для поиска.
- Добавьте фильтруемые поля прав (tenant_id, org_unit_id, visibility_level) в каждый документ.
- Обрабатывайте исключения явными грантами: храните allowlist (ID пользователей) или группы для общих записей.
Совместные записи и исключения — где дизайн чаще ломается. Если тикет может шариться между командами, не добавляйте просто булево поле. Используйте явные гранты, которые можно проверять фильтрами. Если allowlist большой, лучше использовать группы, а не список отдельных пользователей.
Синхронизация индекса без сюрпризов
Безопасный опыт поиска зависит от одной скучной, но важной вещи: индекс должен отражать реальность. Если запись создана, изменена, удалена или её права изменились, результаты поиска должны меняться быстро и предсказуемо.
Обработка создания, обновления и удаления
Рассматривайте индексацию как часть жизненного цикла данных. Полезная модель: при каждом изменении первичного источника вы эмитите событие, и индексатор реагирует.
Распространённые подходы: триггеры базы, события приложения или очередь задач. Главное — события не должны теряться. Если приложение сохранит запись, но не удалось проиндексировать, вы получите «знаю, но поиск не находит», что вводит в заблуждение.
Изменения прав — это изменения индекса
Многие утечки происходят, когда контент обновляется, а метаданные доступа — нет. Изменения прав происходят при обновлении роли, перемещении по команде, смене владельца, перераспределении клиента или слиянии тикетов.
Сделайте изменения прав первоклассными событиями. Если поиск на основе tenant или team, убедитесь, что индексированные документы содержат нужные поля (tenant_id, team_id, owner_id, allowed_role_ids). При изменении этих полей — переиндексируйте.
Сложность — радиус поражения. Смена роли может затронуть тысячи записей. Планируйте путь массовой переиндексации с прогрессом, повторами и возможностью паузы.
Планируйте eventual consistency
Даже при хороших событиях всегда будет окно, когда поиск отстаёт. Решите, что пользователи должны видеть в первые секунды после изменения.
Две полезные практики:
- Будьте последовательны в задержках. Если индексация обычно занимает 2–5 секунд, указывайте это, когда это важно.
- Предпочитайте отсутствие данных утечкам. Лучше, если новая разрешённая запись появится с небольшой задержкой, чем если отозванная запись продолжит показываться.
Безопасный fallback, если индекс устарел
Поиск помогает найти, а просмотр деталей — вот где случается утечка. Делайте вторичную проверку прав при чтении перед показом любых чувствительных полей. Если результат прошёл из-за устаревшего индекса, страница деталей всё равно должна блокировать доступ.
Хорошая практика: показывайте минимальные сниппеты в поиске и повторно проверяйте права при открытии записи (или разворачивании превью). Если проверка не пройдёт, покажите понятное сообщение и уберите элемент из видимого набора при следующем обновлении.
Частые ошибки, ведущие к утечкам
Поиск может выдать данные даже если страница «открыть запись» надёжно защищена. Пользователь может никогда не кликать результат, но всё равно узнать имя, ID клиента или объём скрытого проекта. Permission-aware глобальный поиск должен защищать не только документы, но и косвенные подсказки.
Автозаполнение — частый источник утечек. Подсказки часто работают через быстрый префиксный поиск, пропуская полноценные проверки прав. UI выглядит безобидно, но одна введённая буква может раскрыть имя клиента или почту сотрудника. Автозаполнение должно выполняться с тем же фильтром доступа, что и полный поиск, либо делаться из предварительно отфильтрованного набора подсказок (например, по арендаторам и ролям).
Фасеты и «Примерно 1 243 результата» — ещё одна тонкая утечка. Счётчики подтверждают существование даже при скрытии записей. Если вы не можете безопасно посчитать итоги в рамках тех же правил доступа, показывайте меньше информации или вовсе убирайте счётчики.
Кеширование — ещё частая причина. Общие кеши между пользователями, ролями или арендаторами допускают «призрачные результаты», когда один пользователь видит результаты, сгенерированные для другого. Это может происходить в edge-кешах, в кешах приложения и в памяти сервиса поиска.
Проверочные места:
- Автодополнение и последние запросы фильтруются теми же правилами, что и полный поиск.
- Фасеты и счётчики считаются после применения прав доступа.
- Ключи кеша включают tenant ID и подпись прав (роль, команда, ID пользователя).
- Логи и аналитика не сохраняют сырые запросы или фрагменты результатов для ограниченных данных.
Наконец, остерегайтесь слишком широких фильтров. «Фильтровать только по tenant» — классическая ошибка для multi-tenant, но такое бывает и внутри одного арендатора: фильтрация по «департаменту» при реальных правилах построчно. Пример: агент ищет «refund» и получает результаты по всем клиентам арендатора, включая VIP-аккаунты, которые должны быть видны только узкой команде. Исправление простое в принципе: применяйте построчные правила в каждом пути запроса (поиск, автодополнение, фасеты, экспорт), а не только на странице записи.
Детали приватности и безопасности, которые забывают
Многие проекты фокусируются на «кто что видит», но утечки происходят и через края: пустые состояния, тайминги и мелкие подсказки в интерфейсе. Permission-aware поиск должен быть безопасен даже когда он ничего не возвращает.
Лёгкая утечка — подтверждение отсутствия. Если неавторизованный пользователь ищет по конкретному имени клиента, ID тикета или емейлу и видит сообщение вроде «Нет доступа» или «У вас нет прав», вы подтвердили существование записи. Рассматривайте «нет результатов» как стандартный ответ и для «не существует», и для «существует, но не разрешено». Держите время ответа и формулировки одинаковыми, чтобы по скорости нельзя было догадываться о наличии записи.
Чувствительные частичные совпадения
Автодополнение и поиск по мере ввода — места, где приватность чаще всего прорывается. Частичные совпадения по email, телефонам и госномерам могут раскрыть больше, чем нужно. Решите заранее, как вести себя с этими полями.
Практические правила:
- Требуйте точного совпадения для полей высокого риска (email, телефон, идентификаторы).
- Не показывайте подсвеченные сниппеты, которые раскрывают скрытый текст.
- Рассмотрите возможность отключить автодополнение для чувствительных полей.
Если даже один символ помогает угадать данные — считайте поле чувствительным.
Мера защиты от злоупотреблений без новых рисков
Поисковые endpoint'ы хороши для атак методом перечисления: множество запросов, чтобы найти, что существует. Добавляйте rate limits и обнаружение аномалий, но будьте осторожны с тем, что вы логируете. Логи с сырыми запросами становятся вторым источником утечек.
Просто и надёжно: лимитируйте по пользователю, по IP и по арендатору; логируйте лишь счётчики, время и грубые паттерны (не полный текст запроса); сигнализируйте о повторяющихся «near-miss» запросах (например, последовательные ID); и вводите дополнительные проверки после серии неудач.
Делайте ошибки скучными. Используйте одинаковые сообщения и пустые состояния для «нет результатов», «запись недоступна» и «невалидные фильтры». Чем меньше UI говорит, тем меньше он случайно может выдать.
Пример: команда поддержки ищет тикеты по клиентам
Агент поддержки Майя работает с тремя аккаунтами клиентов. У неё в шапке приложения одно поле поиска. Продукт имеет глобальный индекс по тикетам, контактам и компаниям, но каждый результат должен соответствовать правилам доступа.
Майя вводит «Alic», потому что клиент назвал себя Alice. Автоподсказки показываются быстро. Она кликает «Alice Nguyen — Ticket: Password reset». Перед открытием приложение перепроверяет доступ к этой записи. Если тикет всё ещё назначен её команде и её роль позволяет, она попадает на страницу тикета.
Что Майя видит на каждом этапе:
- Поле поиска: подсказки появляются быстро, но только для записей, к которым она имеет доступ сейчас.
- Список результатов: тема тикета, имя клиента, время последнего обновления. Никаких «у вас нет доступа» плейсхолдеров.
- Детали тикета: полный просмотр загружается только после серверной проверки прав. Если доступ изменился, приложение показывает «Тикет не найден» (а не «запрещено»).
Теперь сравните с Лео, новым агентом на обучении. Его роль позволяет видеть только тикеты, помеченные «Public to Support», и только для одного клиента. Лео вводит ту же «Alic». Он видит меньше подсказок, и отсутствующие варианты никак не подсвечены. Нет счётчика «5 результатов», который бы рассказал о других совпадениях. Интерфейс просто показывает то, что он может открыть.
Позже менеджер переведёт тикет «Alice Nguyen — Password reset» с команды Майи в специализированную команду эскалации. В короткое окно (обычно секунды или минуты, в зависимости от синхронизации) поиск Майи перестаёт возвращать этот тикет. Если у неё открыта страница деталей и она обновит её, приложение перепроверит права и тикет исчезнет.
Именно такое поведение вам и нужно: быстрое ввод и быстрые ответы, без «запахов» данных через счётчики, сниппеты или устаревшие записи в индексе.
Чеклист и следующие шаги для безопасной реализации
Permission-aware глобальный поиск «сделан» только тогда, когда все скучные края безопасны. Многие утечки происходят в местах, которые кажутся безвредными: автодополнение, счётчики и экспорты.
Быстрые проверки безопасности
Перед релизом проведите эти проверки на реальных данных, а не на выборках:
- Автодополнение: никогда не предлагайте заголовок, имя или ID, которые пользователь не может открыть.
- Счётчики и фасеты: если показываете итоги, вычисляйте их после применения прав (или не показывайте вообще).
- Экспорт и массовые операции: экспорт «текущего поиска» должен повторно проверять доступ по каждой строке на момент экспорта.
- Сортировка и подсветка: не сортируйте и не подсвечивайте по полям, которые пользователь не имеет права видеть.
- «Не найдено» vs «запрещено»: для чувствительных сущностей рассмотрите одинаковую форму ответа, чтобы пользователь не мог подтвердить существование записи.
План тестирования
Создайте небольшую матрицу ролей (роли x сущности) и набор данных с намеренно хитрыми случаями: общие записи, недавно отозванный доступ и похожие записи между арендаторами.
Тестируйте в три шага: (1) матричные тесты ролей, где вы проверяете, что запрещённые записи никогда не появляются в результатах, подсказках, счётчиках или экспортах; (2) «попробуй сломать» — вставляйте ID, ищите по email или телефону и пробуйте частичные совпадения, которые не должны ничего возвращать; (3) тесты тайминга и кеша, где вы меняете права и подтверждаете, что результаты обновляются быстро и без устаревших подсказок.
Операционно планируйте день, когда «поиск выглядит неправильно». Логируйте контекст запроса (пользователь, роль, арендатор) и применяемые фильтры прав, но не храните сырые чувствительные запросы или фрагменты. Для безопасной отладки сделайте админский инструмент, который объясняет, почему запись совпала и почему она была разрешена — доступный только администраторам.
Если вы строите на AppMaster (appmaster.io), практический подход — держать поиск как серверный поток: моделируйте сущности и связи в Data Designer, применяйте правила доступа в Business Processes и переиспользуйте ту же проверку прав для автоподсказок, списка результатов и экспортов, чтобы место, где это делается, было одно.


