Практическое руководство

Как перенести права доступа источников в RAG и не раскрыть закрытые документы

Уровень: продвинутый Чтение: до 11 минут Результат: сквозная фильтрация по правам пользователя

Почему фильтра после поиска недостаточно

ACL — список субъектов и правил, определяющих доступ к ресурсу. В типичной реализации RAG документ извлекают из корпоративного источника, разбивают на фрагменты, преобразуют в векторы и помещают в отдельный индекс. Если вместе с текстом не перенести ACL, индекс перестаёт различать общедоступный регламент и закрытый финансовый отчёт.

Утечка происходит раньше генерации ответа. Закрытый фрагмент может попасть в список кандидатов, журнал трассировки, кеш, запрос к модели или отладочный интерфейс. Поэтому инструкция модели «не показывай секретные документы» не является контролем доступа, а удаление запрещённых фрагментов только после генерации запаздывает.

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

Целевая модель

  1. Источник возвращает документ вместе с его идентификатором, версией и правилами доступа.
  2. Ингестор нормализует правила, не расширяя их, и копирует их в каждый фрагмент.
  3. RAG-шлюз получает пользователя только из проверенного сеанса, а не из текста запроса.
  4. Поисковый фильтр ограничивает пространство кандидатов до вычисления ближайших фрагментов.
  5. Авторизатор повторно проверяет кандидатов и актуальность версии перед сборкой контекста.
  6. Кеши, цитаты, журналы и загрузка оригинала используют тот же контекст доступа.

В примерах ниже используются условные JSON и YAML. Это переносимая модель, а не обещание синтаксической совместимости с конкретной векторной базой или системой управления документами.

Шаг 1. Зафиксируйте субъект и стабильные идентификаторы

Не используйте отображаемое имя, адрес электронной почты или название отдела как основной ключ. Они меняются и могут совпадать. Нужен стабильный идентификатор субъекта в пределах известного издателя идентичности. Для нескольких организаций добавьте идентификатор арендатора.

{
  "subject_id": "user-example-17",
  "tenant_id": "tenant-example",
  "issuer": "https://identity.example",
  "groups": [
    "group-engineering"
  ],
  "attributes": {
    "region": "example-region"
  },
  "authenticated_at": "example-timestamp"
}

Все значения здесь условны. На рабочем запросе шлюз должен собрать структуру из проверенного токена или серверного сеанса. Поля subject_id, groups и tenant_id нельзя принимать из JSON, присланного клиентом, HTTP-заголовка произвольного прокси или промпта.

Группы тоже требуют доверенной загрузки. Если токен содержит неполный список из-за ограничения размера, шлюз должен запросить членство у доверенного каталога либо завершить запрос отказом. Молчаливо считать отсутствующий список «доступом ко всему» нельзя.

Шаг 2. Нормализуйте ACL источника без повышения прав

Разные источники описывают доступ по-разному: пользователями, группами, наследованием, ссылками, доменами или метками классификации. Приведите их к небольшой внутренней модели, но сохраните ссылку на исходную политику.

{
  "document_id": "doc-example-42",
  "source_version": "version-example-8",
  "tenant_id": "tenant-example",
  "visibility": "restricted",
  "allow_subjects": [
    "user-example-17"
  ],
  "allow_groups": [
    "group-engineering"
  ],
  "deny_subjects": [],
  "policy_ref": "source-policy-example-42",
  "acl_version": "acl-example-12"
}

Выберите семантику заранее. Безопасный базовый вариант: явный запрет сильнее разрешения, отсутствие ACL означает запрет, неизвестный тип правила означает запрет, а документ одного арендатора никогда не доступен субъекту другого арендатора.

decision =
  same_tenant
  AND NOT explicitly_denied
  AND (
    visibility == "public"
    OR subject_id IN allow_subjects
    OR intersection(user_groups, allow_groups) IS NOT EMPTY
  )

Не преобразуйте сложное правило в более широкое только ради удобства индекса. Если источник допускает условный доступ, который индекс не умеет выразить, храните в индексе грубое ограничение, не расширяющее аудиторию, а точное решение делегируйте авторизатору.

Шаг 3. Перенесите права в каждый фрагмент

После разбиения документ и его ACL не должны разъезжаться. Каждый фрагмент получает document_id, версию содержимого, версию ACL, арендатора и пригодные для фильтрации поля доступа. Ссылка только на отдельную таблицу политик допустима для повторной проверки, но сама по себе не ограничит поиск.

{
  "chunk_id": "doc-example-42:version-example-8:chunk-003",
  "document_id": "doc-example-42",
  "source_version": "version-example-8",
  "chunk_number": 3,
  "text": "Условный текст фрагмента для проверки.",
  "access": {
    "tenant_id": "tenant-example",
    "visibility": "restricted",
    "allow_subjects": [
      "user-example-17"
    ],
    "allow_groups": [
      "group-engineering"
    ],
    "deny_subjects": [],
    "acl_version": "acl-example-12"
  }
}

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

Шаг 4. Примените фильтр до ранжирования

Сформируйте фильтр на сервере из доверенного контекста пользователя. Он должен участвовать непосредственно в запросе к индексу, а не обрезать уже полученный общий список top_k. Иначе запрещённые кандидаты могут попасть в трассировку, а разрешённых результатов окажется слишком мало.

search:
  tenant_filter:
    equals:
      field: access.tenant_id
      value_from: authenticated_context.tenant_id

  access_filter:
    any:
      - equals:
          field: access.visibility
          value: public
      - contains:
          field: access.allow_subjects
          value_from: authenticated_context.subject_id
      - overlaps:
          field: access.allow_groups
          values_from: authenticated_context.groups

  deny_filter:
    not_contains:
      field: access.deny_subjects
      value_from: authenticated_context.subject_id

  on_missing_access_metadata: deny
  on_filter_error: deny

Названия операторов условны. Проверьте документацию своей базы: фильтр массива, фильтр полезной нагрузки и постфильтрация могут иметь разную семантику. Требуемое свойство одно: запрещённый фрагмент не должен становиться кандидатом для вычисления итогового top_k.

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

Шаг 5. Повторите авторизацию перед отправкой в модель

Предфильтр уменьшает поверхность атаки, но не заменяет окончательное решение. Групповое членство могло измениться, ACL — устареть, а адаптер индекса — неверно обработать правило. После поиска передайте идентификаторы кандидатов в независимый авторизатор.

for candidate in retrieved_chunks:
    decision = authorize(
        subject=authenticated_subject,
        document_id=candidate.document_id,
        source_version=candidate.source_version,
        acl_version=candidate.access.acl_version,
        action="read"
    )

    if decision == "allow":
        approved_context.append(candidate)
    else:
        audit_denial(candidate.chunk_id, decision)

send_to_model(approved_context)

Это псевдокод. Авторизатор должен возвращать не только allow или deny, но и причину, версию политики и время решения для аудита. Тайм-аут, недоступность каталога, неизвестная версия ACL и повреждённые метаданные трактуются как отказ.

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

Шаг 6. Закройте вторичные каналы утечки

Компонент Безопасное правило
Кеш поиска Ключ включает арендатора, субъекта или отпечаток полномочий и версию ACL.
Кеш ответа Результат не разделяется между пользователями с разными наборами доступа.
Цитаты Переход к оригиналу повторно проверяет право чтения.
Трассировка Текст фрагментов исключён или доступен только отдельному защищённому контуру.
История диалога Повторное использование контекста учитывает текущие, а не прежние права.
Экспорт и обратная связь Не сохраняют закрытый контекст в общей аналитике или наборе для обучения.

Отзыв доступа должен инвалидировать связанные кеши. Практический способ — включить acl_version или версию снимка полномочий в ключ. Тогда изменение политики делает старое значение недоступным без поиска и удаления каждого кешированного ответа.

Воспроизводимая локальная проверка правил

Следующий пример проверяет только логику разрешений на локальных данных. Он не обращается к сети, не содержит токенов и не имитирует возможности конкретного поискового движка. Сохраните условные записи в chunks.json:

[
  {
    "id": "public-1",
    "access": {
      "tenant_id": "tenant-example",
      "visibility": "public",
      "allow_subjects": [],
      "allow_groups": [],
      "deny_subjects": []
    }
  },
  {
    "id": "group-1",
    "access": {
      "tenant_id": "tenant-example",
      "visibility": "restricted",
      "allow_subjects": [],
      "allow_groups": ["group-engineering"],
      "deny_subjects": []
    }
  },
  {
    "id": "closed-1",
    "access": {
      "tenant_id": "tenant-example",
      "visibility": "restricted",
      "allow_subjects": ["user-other"],
      "allow_groups": [],
      "deny_subjects": []
    }
  }
]

При наличии jq выполните безопасную команду чтения файла:

jq --arg tenant "tenant-example" \
   --arg subject "user-example-17" \
   --argjson groups '["group-engineering"]' '
  map(select(
    .access.tenant_id == $tenant
    and ((.access.deny_subjects // []) | index($subject) | not)
    and (
      .access.visibility == "public"
      or ((.access.allow_subjects // []) | index($subject))
      or ([($groups[] as $g |
            select((.access.allow_groups // []) | index($g)))] | length > 0)
    )
  ))
  | map(.id)
' chunks.json

Ожидаемый результат для этих условных данных:

[
  "public-1",
  "group-1"
]

Запись closed-1 появляться не должна. Затем повторите команду с пустым массивом групп: должен остаться только public-1. Измените арендатора на другое условное значение: результат должен стать пустым.

Проверка всей цепочки RAG

Локальная команда подтверждает формулу, но выпуск требует интеграционной проверки реальной цепочки. Подготовьте искусственные документы без рабочих данных и поместите в каждый уникальный маркер, например MARKER_PUBLIC, MARKER_GROUP и MARKER_DENIED.

  1. Проиндексируйте документы и убедитесь, что каждый фрагмент содержит ожидаемые версии и ACL.
  2. Выполните поиск от лица пользователя без группового доступа.
  3. Проверьте отсутствие MARKER_GROUP и MARKER_DENIED в кандидатах, контексте, ответе, цитатах, кеше и трассировке.
  4. Повторите запрос с разрешённой группой: должен добавиться только MARKER_GROUP.
  5. Отзовите членство или доступ, обновите индекс и повторите запрос без очистки клиента вручную.
  6. Искусственно вызовите ошибку авторизатора: запрос должен завершиться отказом, а не поиском без фильтра.

Проверяйте именно промежуточные артефакты. Корректный финальный ответ ещё не доказывает отсутствие утечки: модель могла не процитировать закрытый текст, уже записанный в трассировку.

Типовые ошибки

  • Один общий технический пользователь. Источник видит права сервиса, а не конечного читателя. Переносите ACL при индексации и авторизуйте каждый запрос отдельно.
  • Фильтрация после общего top_k. Закрытые кандидаты уже извлечены, а разрешённые вытеснены. Ограничивайте пространство поиска заранее.
  • ACL только на уровне документа. Фрагменты теряют связь с политикой. Копируйте минимально необходимые поля и идентификатор версии в каждый фрагмент.
  • Отсутствующие метаданные означают публичность. Ошибка коннектора превращается в утечку. Используйте запрет по умолчанию.
  • Доверие группам из запроса. Клиент может добавить себе роль. Группы поступают только из проверенной идентичности или доверенного каталога.
  • Кеш только по тексту вопроса. Пользователь с меньшими правами получает чужой ответ. Связывайте кеш с контекстом доступа.
  • Удаление документа только из основного хранилища. Векторы, кеши и трассировки продолжают жить. Введите процедуру удаления по document_id во всех хранилищах.
  • Разрешение при сбое. Тайм-аут каталога или ошибка фильтра не должны отключать проверку.

Ограничения подхода

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

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

Шифрование хранилища защищает носитель, но не исправляет ошибочную выдачу авторизованному процессу. Разделение индексов уменьшает радиус инцидента, но не заменяет проверку субъекта. Наконец, ACL контролирует право чтения, а не допустимость отправки данных внешней модели: для этого нужна отдельная политика обработки и размещения данных.

Контрольный список перед выпуском

  • Идентичность получена из проверенного серверного контекста.
  • Арендатор фильтруется во всех запросах без исключений.
  • Каждый фрагмент содержит идентификатор документа, версию содержимого и версию ACL.
  • Неизвестные, пустые и повреждённые правила приводят к отказу.
  • Фильтр применяется внутри поиска, до формирования кандидатов.
  • Кандидаты повторно авторизуются перед сборкой промпта.
  • Кеши, цитаты, история и журналы подчиняются тем же правилам.
  • Отзыв доступа проверен на искусственных данных во всех промежуточных системах.

Другие схемы безопасной эксплуатации агентов собраны в практических руководствах, а определения терминов — в глоссарии Agent Lab Journal.