Сотрудник клиента A спросил помощника о скидках. В ответе появилась сумма из договора клиента B. Модель ничего не взламывала: приложение сначала сделало глобальный vector search, а tenant_id попыталось проверить после получения top-10. Документы A просто не вошли в десятку.
Дальше всё пошло по обычному сценарию: инцидент, письмо клиенту, вопрос юристов «а сколько ещё раз это происходило» и мучительное открытие, что ответить на него нельзя, потому что в логах лежит только текст ответа, а не список источников.
Права доступа являются условием retrieval, а не фильтром перед показом ответа. То же относится к удалению, сроку действия и уровню конфиденциальности.
Граница данных
identity -> tenant membership -> document policy -> search candidates
-> prompt -> answerИдентификатор tenant берут из проверенной сессии или service credential, но не из произвольного поля запроса. В PostgreSQL полезно дублировать защиту Row-Level Security:
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_chunks ON chunks
USING (tenant_id = current_setting('app.tenant_id')::uuid);Транзакция должна установить app.tenant_id из аутентифицированного principal. Сервисная роль, обходящая RLS, превращает защиту в декорацию.
async def search_for_user(db, principal, query_embedding):
async with db.transaction():
await db.execute(
"select set_config('app.tenant_id', $1, true)",
str(principal.tenant_id),
)
return await db.fetch("""
select id, document_id, text
from chunks
where visibility = 'tenant'
order by embedding <=> $1
limit 8
""", query_embedding)Здесь важна не столько сама политика, сколько её расположение: она стоит под приложением. Любой новый эндпойнт, отладочный скрипт, джоба переиндексации и джуниор с ноутбуком получают её бесплатно, ничего для этого не сделав. Защита, которую нужно не забыть добавить, — это не защита, а привычка, и однажды её нарушит человек, у которого сегодня плохой день.
Для ACL сложнее tenant добавляют таблицу разрешений и фильтруют кандидатов в SQL. Индекс и объектное хранилище должны удаляться согласованно. Запись deleted_at в каталоге бесполезна, если старый чанк остаётся в поисковом индексе ещё сутки.
Фильтровать до, а не после
Причину инцидента из начала главы разберём отдельно: ошибка выглядит безобидно и живёт почти в каждом прототипе.
Поиск «сначала глобально, потом отфильтруем» ломает сразу две вещи:
Безопасность. Между «нашли» и «отфильтровали» лежит код. Код меняют. Однажды кто-то добавит логирование кандидатов, вернёт их в отладочный ответ или напишет except Exception: pass в неудачном месте, и чужие документы поедут дальше.
Качество. Top-10 глобального поиска в системе с тысячей арендаторов состоит из чужих документов на девяносто девять процентов. После фильтрации остаётся ноль или один кандидат, и пользователь видит «ничего не найдено» при полной базе своих документов. Безопасность здесь не куплена ценой качества: потеряно и то и другое.
Фильтр в запросе к индексу решает обе проблемы. В pgvector это where рядом с оператором расстояния, во внешних движках — pre-filter, а не post-filter; проверьте, что ваш движок умеет именно первое.
Уровни доступа внутри одного tenant
Мультиарендность — самый заметный случай, но редко единственный. Внутри компании-клиента границы тоже есть: HR-документы, зарплаты, договоры с поставщиками, черновики.
Практичная модель — три поля у документа:
tenant_id— кому принадлежит;visibility(public,tenant,group,owner) — кому в принципе видно;acl_version— версия набора разрешений, нужна для кэшей.
Ошибка проектирования, которую потом трудно исправить, — хранить права только в исходной системе (в Confluence, в диске, в CRM) и синхронизировать «когда-нибудь». Права меняются мгновенно, синхронизация — раз в час, и всё это время уволенный сотрудник продолжает получать ответы по документам, к которым доступ уже закрыт. Если синхронизация неизбежна, у неё должна быть измеряемая задержка и алерт на отставание.
Кэш, логи и остальные чёрные ходы
Данные утекают не только через ответ. Каналы, которые проверяют реже всего:
Кэш. Ключ по тексту вопроса — самый быстрый способ отдать ответ клиента A клиенту B. В ключ входят tenant_id, идентификатор пользователя или группы, acl_version и версия индекса. При изменении прав кэш инвалидируется, иначе увольнение сотрудника вступит в силу через сутки.
Логи и trace. Полный prompt в trace — это копия документов клиента в системе, где права доступа настроены совсем иначе: «вся команда разработки». Prompt в трейсе либо редактируется, либо хранится по ссылке на защищённое хранилище с теми же правами, что у оригинала.
Индексируемые поля. В чанк вместе с полезным текстом уезжают подписанные URL, токены, e-mail и телефоны. Потом всё это находится обычным поиском и цитируется моделью в ответе. Фильтрация PII делается до индексации (глава 29), а не после генерации.
Что обычно ломают
- Принимают
tenant_idиз JSON и забывают сверить membership. - Ищут глобально, затем отбрасывают запрещённое. Это ухудшает и безопасность, и recall.
- Кэшируют ответ только по тексту вопроса, без tenant и версии ACL.
- Пишут полный prompt в trace, доступный общей группе инженеров.
- Индексируют подписанные URL, токены и персональные поля вместе с полезным текстом.
- Считают удаление документа завершённым до очистки чанков, кэшей и резервных копий по принятой политике.
Тест на изоляцию должен создавать одинаковые документы в двух tenant с разными секретными маркерами. Запрос от A не должен вернуть маркер B ни в ответе, ни в citations, логах и trace.
Правило главы
Авторизация происходит до ранжирования и сохраняется на всём пути данных. Если запрещённый фрагмент успел попасть в prompt, проверка ответа уже опоздала.
Чеклист главы
Практикум
Задача 22.1 — изоляция, доказанная тестом. ⭑⭑
Создайте два tenant, по три пользователя и документы с пересекающимся текстом и уникальными секретными маркерами.
Критерии приёмки:
Подсказки:
- Уникальный маркер вида
TENANT-A-CANARY-7Qищется по логам одной командой и не даёт спорить о результате. - Тест «политика отключена — тест красный» полезнее самого теста изоляции: он защищает от тихого отключения RLS в будущем.
Задача 22.2 — кэш и смена прав. ⭑⭑
Введите tenant-aware ключ кэша и проверьте поведение при изменении прав в середине жизни кэша.
Критерии приёмки:
Подсказки:
acl_versionпроще всего инкрементировать триггером при изменении разрешений.- Если hit rate после изменения ключа рухнул, посмотрите, не слишком ли мелкая у вас единица кэширования: часто кэшировать стоит retrieval, а не финальный ответ.
Задача 22.3 — удаление, доведённое до конца. ⭑⭑⭑
Реализуйте полное удаление документа и докажите, что оно завершено.
Критерии приёмки:
Подсказки:
- Начните с документа-канарейки, который создаётся и удаляется по расписанию: он покажет реальную задержку без участия людей.
- Самое частое место, где документ «остаётся жить», — не индекс, а кэш эмбеддингов или временные файлы конвертера.