0047. Лимиты расхода LLM и контроль доступа в RAG-поиске
Копия ADR из репозитория:
backend/docs/adr/0047-llm-spend-limits-and-rag-access-control.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-08-05
- Связанные задачи: аудит безопасности 2026-08-05 (находка 6, OWASP LLM10; находка 10, CWE-863)
Контекст и проблема
1. Расход LLM ничем не ограничивался. LLM_API_KEY — один на всех арендаторов,
поэтому его исчерпание кладёт ИИ-функции сразу у всех селлеров. При этом:
max_tokensне уходил в провайдера никогда: адаптер подставлял поле только при явномrequest.maxTokens(openai-compatible.provider.ts), а его не передавал ни один вызывающий сервис — верхней границы стоимости одного ответа не существовало;AskQuestionDto.questionне имел@MaxLength(соседнийParseMarketerTaskDto— имел): длина входа, то есть цена промпта, не ограничивалась;- ни входящего rate-limit'а, ни квоты не было;
usage.totalTokensтолько писался в лог.
2. RAG-поиск ИИ-помощника обходил контроль доступа к материалам.
KnowledgeChunkRepository.searchTopK выполнял
SELECT … FROM knowledge_chunks ORDER BY embedding <=> $1 LIMIT $2 — без единого условия
доступа, а AiAssistantService.ask не получал актёра вовсе (контроллер передавал только
DTO). Штатный путь при этом реализован правильно: KnowledgeBaseService.searchMaterials
заканчивается filterAccessible, правила живут в чистой функции canAccessMaterial
(admin / автор / draft-archived / restricted + грант).
Находка латентна ровно до появления загрузки документов (KbIngestService пока не имеет
вызывающих), но становится раскрытием чужих материалов в тот же день, когда загрузка
появится: содержимое чужого restricted-материала утекало бы не как ссылка, а как текст
в ответе ассистента — без следа в аудите доступа.
Рассмотренные варианты
По контролю доступа в RAG:
- Пост-фильтрация выдачи по правам. Просто и не требует миграции, но ломает сам смысл top-k: если ближайшие 5 фрагментов недоступны, пользователь получает пустой контекст вместо доступного ответа. Безопасно, но функционально деградирует тем сильнее, чем больше в корпусе чужих материалов.
- JOIN
kb_materialsв поисковом запросе. Права всегда свежие, денормализация не нужна. Минус — джойн и вычисление предиката на каждой строке kNN-обхода, плюс жёсткая привязка поискового слоя к схеме модуля 5.15. - Денормализация прав в
knowledge_chunks+ фильтр вWHERE. Предикат считается по колонкам самой таблицы фрагментов, top-k набирается уже из доступного. Минус — денормализованная копия может устареть.
По учёту расхода: глобальный лимит на приложение (не защищает от одного «жадного» пользователя), лимит на IP (throttler — защищает от частоты, но не от объёма) и суточная квота токенов на пользователя.
Решение
Контроль доступа — вариант 3 (денормализация + SQL-фильтр), с двумя страховками.
Миграция 1786200000000-AddKnowledgeChunkAccess добавляет в knowledge_chunks колонки
material_id (FK → kb_materials, ON DELETE CASCADE), visibility, status,
author_user_id (FK → users, ON DELETE SET NULL). KbIngestService заполняет их при
индексации. searchTopK принимает обязательного актёра и фильтрует доступ в WHERE —
до ORDER BY … LIMIT; предикат зеркалит все пять правил canAccessMaterial. Гранты
(kb_access_grants) не денормализуются: они проверяются живым EXISTS — отзыв гранта
обязан действовать немедленно.
Страховки:
- Fail-closed по умолчанию. Фрагмент без
material_id(например, проиндексированный напрямую черезRagService.indexбезaccess) виден только администратору. Фрагменты soft-deleted материала — тоже. - Повторная проверка выдачи. Каждая возвращённая строка прогоняется через саму
canAccessMaterial; расхождение с SQL-предикатом отбрасывает фрагмент и пишется в лог как дефект (rag.access-drift). Это защищает от рассинхрона при будущем изменении правил и делаетcanAccessMaterialединственным источником истины по правилам.
Актёр протащен по всей цепочке: контроллер → AiAssistantService.ask(actor, …) →
RagService.buildContext(query, actor, …) → searchTopK(vector, topK, actor).
KnowledgeBaseService.searchMaterials тоже передаёт актёра в RAG и дополнительно
сохраняет собственную filterAccessible (defence in depth).
Лимиты расхода — три независимых контроля.
max_tokensпо умолчанию. Адаптер подставляетLLM_MAX_TOKENS(config.llm.maxTokens), если вызывающий не задал лимит: потолок цены одного ответа теперь есть всегда.@ThrottleLlm()на всех LLM-роутах — частота обращений с IP:POST /ai-assistant/{ask,analyze},POST /ai-agent/tasks/{parse,plan},POST /content/cards/generate,POST /legal/claims,GET /knowledge-base/search(последний векторизует запрос на каждый вызов).- Суточная квота токенов на пользователя (
LLM_DAILY_TOKEN_QUOTA, константа модуля, не env — это предохранитель, а не тариф). Счётчик —INCRBYв Redis по ключуop:llm:quota:{userId}:{YYYY-MM-DD}с TTL до полуночи UTC; списывается фактическийusage.totalTokens(генерация и эмбеддинги). Проверка — интерцептором@LlmQuota()до вызова обработчика; при исчерпании —429+Retry-Afterдо конца суток.
Валидация входа: @MaxLength(2000) на AskQuestionDto.question (как у
ParseMarketerTaskDto.text) и @MaxLength(500) на SearchKbQueryDto.query.
Последствия
-
Положительные:
- фрагменты чужих и неопубликованных материалов не попадают в контекст ИИ-помощника, и top-k при этом не «съедается» недоступным — качество ответа не деградирует;
- стоимость ограничена на трёх уровнях: длина входа, потолок ответа, суточный объём;
canAccessMaterialостаётся единственным источником правил доступа — SQL-предикат проверяется против него на каждой выдаче;- расход прозрачен: квота считается по фактическому
usage, а не по оценке.
-
Отрицательные / компромиссы:
- денормализованная копия прав может устареть. В текущей модели материал после
создания не меняет
status/visibility/автора (метода обновления вKnowledgeBaseServiceнет), поэтому копия консистентна. При появлении пути редактирования материала он обязан обновлять эти колонки у фрагментов — инвариант зафиксирован в TSDoc миграции. Переиндексация материала эту проблему тоже снимает; - квота списывается постфактум: один запрос может перешагнуть лимит, потому что
токены известны только после ответа. Перебор ограничен сверху
LLM_MAX_TOKENS; - учёт квоты fail-open: сбой Redis трактуется как «квота не исчерпана», иначе падение кэша выключало бы ИИ-функции целиком. Частотный лимит при этом продолжает работать;
- квота считается по пользователю, а не по арендатору: справедливее было бы по кабинету
WB, но у части LLM-роутов (
ask,tasks/parse,content/cards/generate) кабинета в запросе нет вовсе.
- денормализованная копия прав может устареть. В текущей модели материал после
создания не меняет
-
Дальше:
POST /content/cards/generateиPOST /legal/claimsпока ограничены только частотой (@ThrottleLlm()); подключить к ним@LlmQuota()и списаниеusageв их сервисах;GenerateCardContentDto(5.13) иCreateClaimDto(5.12) не имеют предела длины текстовых полей — закрыть по образцуAskQuestionDto;- при появлении биллинга заменить константу квоты таблицей лимитов per-арендатор.