Перейти к основному содержимому

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:

  1. Пост-фильтрация выдачи по правам. Просто и не требует миграции, но ломает сам смысл top-k: если ближайшие 5 фрагментов недоступны, пользователь получает пустой контекст вместо доступного ответа. Безопасно, но функционально деградирует тем сильнее, чем больше в корпусе чужих материалов.
  2. JOIN kb_materials в поисковом запросе. Права всегда свежие, денормализация не нужна. Минус — джойн и вычисление предиката на каждой строке kNN-обхода, плюс жёсткая привязка поискового слоя к схеме модуля 5.15.
  3. Денормализация прав в 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).

Лимиты расхода — три независимых контроля.

  1. max_tokens по умолчанию. Адаптер подставляет LLM_MAX_TOKENS (config.llm.maxTokens), если вызывающий не задал лимит: потолок цены одного ответа теперь есть всегда.
  2. @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 (последний векторизует запрос на каждый вызов).
  3. Суточная квота токенов на пользователя (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-арендатор.