Перейти к содержимому
PD
Разработка SaaS 8 мин чтения

Ваш AI-поиск игнорирует права доступа: как я строю permission-aware retrieval в SaaS

Изоляции тенантов недостаточно, когда в продукте появляется RAG. Рассказываю, как заставить AI-поиск и ассистента уважать права на каждый документ, отзыв доступа и производные данные в multi-tenant SaaS.

PD

Pavel Duglas

AI Automation & MVP Architect

Однажды клиент показал мне баг-репорт, который выглядел безобидно: «AI-ассистент знает про реорганизацию». Джуниор из отдела продаж спросил встроенного ассистента о планах своей команды и получил ответ с деталями из HR-документа, который могли открыть только три директора. При этом все проверки тенанта прошли. Сотрудник был в своём воркспейсе и смотрел данные своей же компании. Проблема в том, что ассистент ни разу не спросил, может ли именно этот человек видеть именно этот документ. Он проверил только одно: работает ли человек в этой компании.

Это самая частая дыра в безопасности, которую я нахожу на аудитах AI-фич в SaaS. В основном приложении аккуратная модель прав. Потом кто-то добавляет retrieval, и индекс тихо сплющивает её до «всё, что есть в тенанте». Ниже - как я строю поиск, который уважает права с первого дня.

Изоляция тенантов - это пол, а не модель прав

Изоляцию тенантов большинство команд делают нормально, потому что её поломка очень громкая. Клиент A видит данные клиента B - это уже скандал. Поэтому tenant_id добавляют в каждую таблицу, каждый запрос, каждый namespace в векторной базе, и становится спокойно.

Но внутри тенанта у живого продукта есть слои: личные документы, папки команд, проекты с приглашёнными гостями, настройки только для админов, записи конкретного владельца. Обычный API проверяет эти слои на каждом чтении. AI-фича, как правило, нет, и причина простая: векторный индекс - это копия ваших данных, а копия теряет правила доступа, если их не перенести осознанно.

Когда вы режете документ на чанки и считаете эмбеддинги, на выходе текст и вектор. ACL живёт отдельно, в реляционной базе, и между ними нет связи. Дальше ассистент ищет по похожести, а похожесть понятия не имеет, кто задаёт вопрос.

Правило: фильтруем до поиска по похожести, а не после

Первое, что приходит в голову: достать top-k чанков и выкинуть те, что пользователю недоступны. Это post-filtering, и я его избегаю по трём причинам.

  1. Пустая выдача. Достали 10 чанков, пользователю доступен 1 - качество ответа проваливается. У людей с узким доступом оно проваливается всегда.
  2. Данные видят до фильтра. Реранкеры, логирующий middleware, дебажные трейсы часто работают с полным top-k. Значит, закрытый текст уже лежит в логах и, возможно, ушёл во внешний API реранкера.
  3. Про него легко забыть. Один новый код-путь без фильтра - и утечка готова. Безопасность, которая держится на том, что каждый разработчик вспомнит про шаг, безопасностью не является.

Правильный подход - pre-filtering: ограничение по правам становится частью самого поискового запроса, и закрытые чанки вообще не попадают в кандидаты.

Храним метаданные доступа на каждом чанке

Каждый чанк несёт список principals, которым разрешено его читать. Я храню плоский массив ID, а не исходную структуру ACL: векторные базы хорошо фильтруют по массивам и плохо по вложенной логике.

chunk = {
    "id": "doc_812#c4",
    "tenant_id": "t_19",
    "source_id": "doc_812",
    "acl_version": 7,
    "allowed_principals": ["user:44", "group:directors", "role:admin"],
    "text": "...",
    "embedding": [...],
}

Principals - это пользователи, группы и роли в одном пространстве имён. Документ, расшаренный на команду, получает group:team_sales, а не список всех её участников. Так индекс не приходится трогать каждый раз, когда кто-то приходит в команду или уходит из неё.

Собираем principals пользователя в момент запроса

На каждом запросе вычисляем полный набор principals текущего пользователя и передаём его как фильтр.

def search(user, query, k=8):
    principals = [f"user:{user.id}"]
    principals += [f"group:{g}" for g in user.group_ids()]
    principals += [f"role:{r}" for r in user.roles()]

    return vector_db.query(
        vector=embed(query),
        top_k=k,
        filter={
            "tenant_id": user.tenant_id,
            "allowed_principals": {"$in": principals},
        },
    )

Обратите внимание: фильтр по тенанту никуда не делся. Проверка principals - это вторая стена, а не замена первой. И функция принимает user, а не tenant_id. Я специально делаю так, чтобы поиск без личности вызвать было невозможно. Если фоновой задаче нужен системный поиск, пусть пишут отдельную функцию с таким названием, от которого ревьюеру станет неуютно.

Права меняются, а индекс об этом не знает

Pre-filtering закрывает статичный случай. Сложность во времени. В 10:00 гостя убрали из проекта. Переиндексация идёт раз в сутки ночью. Ещё 14 часов этот гость может спрашивать ассистента про проект.

Я решаю это двумя механизмами одновременно.

Отправляем изменения ACL событиями

Каждое изменение прав в основном приложении генерирует событие: документ расшарили, закрыли, перенесли в другую папку, поменялся состав группы. Воркер читает эти события и обновляет allowed_principals у затронутых чанков. Это патч метаданных, а не пересчёт эмбеддингов, поэтому он дешёвый и быстрый. Большинство векторных баз умеют обновлять метаданные по фильтру на source_id.

И здесь групповые principals окупаются: если пользователя убрали из group:team_sales, индекс вообще не трогаем, потому что его набор principals просто сужается при следующем запросе.

Перепроверяем по источнику истины перед ответом

События задерживаются, воркеры падают, очереди забиваются. Поэтому после retrieval, до того как чанк попадёт в prompt, я делаю одну дешёвую проверку по основной базе:

hits = search(user, query)
source_ids = {h.source_id for h in hits}
readable = permissions.filter_readable(user, source_ids)  # один SQL-запрос
context = [h for h in hits if h.source_id in readable]

Да, выглядит как тот самый post-filtering. Разница в том, что это страховочная сетка поверх pre-filtering, а не основной механизм. На практике она почти ничего не отсекает. А когда отсекает, я пишу это в метрику отставания синхронизации. Если метрика растёт, значит, сломан пайплайн событий, и я хочу узнать об этом раньше клиента.

Настоящие утечки прячутся в производных данных

Чанки - очевидная часть. Утечки, которые я реально нахожу на аудитах, сидят во всём, что AI-фича из них производит.

  • Сгенерированные саммари. «Еженедельный дайджест по проекту» из десяти документов - это новый документ. Кто имеет право его читать?
  • Кешированные ответы. Кешируете ответ на «какой у нас план на Q3?» на уровне тенанта - и следующий пользователь получает ответ, собранный с правами первого.
  • История диалогов. Если прошлые чаты эмбеддятся для памяти, чат директора теперь содержит закрытые данные и лежит в индексе.
  • Извлечённые сущности и теги. Структурированные данные из закрытого документа всё равно раскрывают, что в нём написано.

Правило у меня простое: производный артефакт наследует пересечение прав всех своих источников. Если саммари построено по документу для отдела продаж и документу для директоров, читать его могут только те, кто есть в обоих списках. Часто это практически никто, и это хороший сигнал: такой артефакт надо генерировать под конкретного пользователя или не хранить вовсе.

Для кеша ключ включает хеш использованных source ID и их версий ACL, а сам ответ отдаётся только после той же проверки доступности. Память диалогов по умолчанию приватна для пользователя и никогда не попадает в общий индекс.

Tools работают от имени пользователя, а не сервиса

Как только ассистент получает инструменты вроде «найти счёт» или «показать открытые тикеты», та же проблема возвращается в новой форме. Самая быстрая реализация дёргает внутренний API сервисным токеном, который видит всё в тенанте. И теперь решение, что показать пользователю, принимает модель. А модель - не система авторизации.

Каждый вызов tool выполняется с правами пользователя, который задал вопрос, через тот же слой авторизации, что и обычный API. Если человек не может открыть счёт 4411 в интерфейсе, инструмент получает 403, и ассистент честно говорит, что доступа нет. Никакого отдельного AI-пути, никаких обходов. Если инструменту действительно нужен повышенный доступ, он возвращает минимальный производный факт, например количество, и это решение задокументировано и прошло ревью.

Набор тестов на утечки, который я добавляю в каждый проект

Ошибки в правах не всплывают на демо по счастливому сценарию, поэтому я пишу тесты, которые специально их ищут. Они гоняются в CI на засеянном тестовом тенанте.

  1. Канареечные документы. Кладём закрытый документ с уникальной строкой вроде CANARY-7Q2X. Логинимся пользователем без доступа, задаём десяток вопросов, заточенных под то, чтобы её вытащить, и проверяем, что строка не появляется ни в ответе, ни в цитатах, ни в логах, ни в трейсах.
  2. Тест отзыва доступа. Расшариваем документ, убеждаемся, что ассистент его использует, отзываем доступ, ждём воркер и проверяем, что больше не использует. Потом гасим воркер и убеждаемся, что проверка по источнику истины всё равно блокирует.
  3. Один тенант, разные пользователи. Двое в одном тенанте с непересекающимся доступом. Ни один запрос первого не должен вернуть чанки, которые принадлежат только второму.
  4. Тест производных артефактов. Генерируем саммари из источников с разными правами и проверяем, что пользователь с частичным доступом не может его получить.
  5. Тест обхода через tools. Просим ассистента достать запись по ID, к которой у пользователя нет доступа, и проверяем, что инструмент вернул отказ.

Один только канареечный тест дважды ловил у меня реальные утечки, и оба раза в логах, а не в ответах.

Мой чеклист перед релизом AI-поиска

  • У каждого чанка есть tenant_id, source_id, allowed_principals и acl_version.
  • Функция поиска требует объект пользователя и сама строит фильтр по principals.
  • Retrieval идёт с pre-filtering, пост-проверка - только страховка с метрикой.
  • Изменения ACL отправляются событиями и патчат метаданные индекса за секунды.
  • Саммари, кеши и память наследуют пересечение прав источников.
  • Tools работают через обычный слой авторизации от имени пользователя.
  • Канареечные тесты и тесты отзыва доступа крутятся в CI.
  • В логах и трейсах нет текста чанков, полученного до фильтрации по правам.

Ничего экзотического здесь нет. Это та же дисциплина, которую вы уже применяете к API, просто распространённая на ту часть системы, про которую все забывают, что это тоже чтение данных. Если ассистент может ответить на вопрос, значит, он эти данные прочитал. Относитесь к нему соответственно.

Вопросы и ответы

Можно ли обойтись отдельным namespace или индексом на каждого пользователя вместо фильтра по principals?

Технически можно, но я так не делаю. Один и тот же документ, доступный команде из 50 человек, придётся хранить в 50 копиях, а любое изменение прав превращается в копирование и удаление эмбеддингов. Namespace на тенант плюс фильтр по allowed_principals внутри него масштабируется гораздо лучше и обновляется дешёвым патчем метаданных.

Что делать, если векторная база плохо фильтрует по метаданным или фильтр сильно замедляет поиск?

Сначала проверьте, что поле с principals проиндексировано как фильтруемое, у большинства баз это отдельная настройка. Если у пользователя сотни групп, сворачивайте их в роли или иерархию и расширяйте набор principals на стороне приложения. Если база в принципе не поддерживает pre-filtering, я бы сменил её до релиза, а не пытался лечить это пост-фильтрацией.

Нужна ли вся эта схема в MVP, где пока нет сложной модели прав?

Полный пайплайн событий в MVP может подождать, но поля allowed_principals и acl_version на чанках и функцию поиска, требующую пользователя, я закладываю сразу. Это час работы сейчас против переиндексации всего корпуса и аудита логов потом, когда появятся приватные документы и первый корпоративный клиент спросит про безопасность.

Похожие статьи