Чат-интерфейс стал стандартом: пользователь пишет «верни деньги за заказ #123», агент вызывает инструмент. Проблема в том, что агент — ненадёжный актор: он галлюцинирует и поддаётся prompt injection. Если отдать ему токен пользователя «как есть», это примерно как выдать пароль от базы стажеру, который иногда слышит голоса. Ниже — рабочий пример, который решает задачу стандартными инструментами и поднимается одной командой.

Почему токен пользователя нельзя передавать агенту напрямую

LLM-агент, читающий инъекцию из пользовательского ввода, — учебниковый confused deputy: программа с чужими полномочиями, которую обманом заставляют использовать их не по назначению. Термин описал Norm Hardy ещё в 1988 году, и лекарство известно с тех же пор — не давать заместителю лишней власти и проверять полномочия у последнего рубежа.

Из мира PAM (privileged access management) пришёл принцип zero standing privileges: администратор не «имеет» root постоянно, а запрашивает повышение под конкретную задачу на короткое время. Агенту нужно ровно то же самое.

БылоСтало
Сервисный аккаунт с правами «про запас»Токен, урезанный до одного инструмента и одной аудитории
Общий пароль от базы в конфигеПользовательский контекст, проброшенный до row-level security
sudo у дежурного админаStep-up: человек подтверждает действие, токен живёт 120 секунд

Какие стандарты решают задачу

Изобретать ничего не нужно — всё уже стандартизировано.

RFC 8693 (Token Exchange) — аттенуация токена: меняем токен пользователя на более слабый, сужая aud и scope. Запросить больше, чем было у субъекта, нельзя — обмен только сужает.

RFC 9449 (DPoP) — токен привязывается к ключу приложения (claim cnf.jkt), плюс одноразовый proof на каждый запрос. Украденный токен без ключа — мёртвый груз.

OpenID CIBA — human-in-the-loop как протокол: клиент инициирует, человек подтверждает, клиент получает токен. Идеальная форма для step-up подтверждений прямо в чате.

Row-Level Security в PostgreSQL — полномочия проверяются в самом хранилище, под любой ошибкой приложения. Это последний рубеж.

Спецификация авторизации MCP требует OAuth 2.1 для remote-серверов, DPoP-расширение (SEP-1932) проходит конформанс. Полный набор «OIDC + DPoP + CIBA» из коробки умеют немногие: Keycloak ≥ 26.4, oidc-provider (Node.js-библиотека), Attesto на Elixir, issuerd на Rust, из коммерческих — Duende и Connect2id.

Как устроена база: RLS — это последний рубеж

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

sql
-- db-init/01-shop.sql
CREATE ROLE mcp_user LOGIN PASSWORD '...';

CREATE TABLE orders (
  id        integer PRIMARY KEY,
  owner_sub uuid    NOT NULL,
  item      text    NOT NULL,
  amount    numeric(10,2) NOT NULL,
  status    text    NOT NULL DEFAULT 'paid'
);

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY orders_owner ON orders
  USING (owner_sub = current_setting('app.user_sub', true)::uuid);

Три момента, которые здесь важны.

Во-первых, приложение подключается не владельцем таблицы: PostgreSQL не применяет RLS к владельцу, поэтому заведена отдельная роль mcp_user с правами SELECT, UPDATE и нулём прав на остальное.

Во-вторых, контекст ставится на транзакцию через SELECT set_config('app.user_sub', $1, true) — sub берётся из JWT, параметризовано, значение не попадает в текст SQL.

В-третьих, current_setting(..., true) работает в режиме missing-ok: забыло приложение поставить контекст — получи ноль строк, а не ошибку и не все строки таблицы.

Поднимаем демо: заказы, prompt injection и украденный токен

Сценарий — чат поддержки магазина с двумя инструментами: get_orders() и refund_order(order_id, amount).

plain
браузер ──► ChatApp (чат, :5108) ──► IdP (OIDC, :8080)
                │
                └──► McpServer (только внутр. сеть) ──► PostgreSQL
                          JWT + DPoP + scope
                          RLS по sub пользователя

Нужен только Docker:

bash
git clone https://github.com/issuerd/issuerd.git
cd issuerd/examples/agentic-mcp
docker compose up -d

Открываем http://localhost:5108, логинимся как alice / changeme — и можно ломать.

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

bash
docker compose run --rm setup python verify.py

Скрипт заканчивается ALL CHECKS PASSED. По умолчанию агент работает в scripted-режиме на регулярках — детерминированный автомат, удобный для самопроверки. Живой LLM подключается через переменные окружения LLM_BASE_URL, LLM_API_KEY, LLM_MODEL; протокольная часть от этого не меняется.

Где ломается

Забытый контекст транзакции. Если приложение не вызвало set_config('app.user_sub', ...) перед запросом, политика вернёт ноль строк — это безопасный сбой, но молчаливый. Логируйте отсутствие контекста явно.

Роль владельца таблицы. PostgreSQL не применяет RLS к владельцу объекта. Если приложение подключается тем же пользователем, который создавал таблицы, политики не работают. Всегда заводите отдельную роль с минимальными правами.

Долгоживущие токены. DPoP не спасает, если токен выдан на часы, а ключ скомпрометирован вместе с ним. Для step-up действий (возврат денег, удаление данных) время жизни токена должно быть коротким — в примере это 120 секунд.

Prompt injection через данные. Агент может прочитать из базы строку, содержащую инструкцию вида «игнорируй предыдущие команды». RLS защищает строки по владельцу, но не фильтрует содержимое. Валидируйте и экранируйте всё, что агент получает из внешних источников, перед передачей в контекст.

Конформанс DPoP в MCP. SEP-1932 ещё проходит конформанс — спецификация живая, детали могут меняться. Следите за обновлениями, если строите продакшн-решение прямо сейчас.

Что попробовать дальше

Запустите verify.py и посмотрите, как падают три атаки — это быстрее любой документации объясняет, что именно защищает каждый слой. Дальше — подключите живую модель через LLM_BASE_URL и проверьте, удастся ли вам обойти политики через нестандартные формулировки запросов. Если нужен Keycloak вместо issuerd, схема та же: Token Exchange + DPoP + CIBA + RLS.


FAQ

Зачем нужна отдельная роль mcp_user, если можно использовать владельца таблицы?

PostgreSQL не применяет Row-Level Security к владельцу объекта. Если приложение подключается той же ролью, которая создавала таблицы, политики RLS просто игнорируются и агент получает доступ ко всем строкам.

Что такое DPoP и зачем он нужен агенту?

DPoP (RFC 9449) привязывает токен к криптографическому ключу приложения. На каждый запрос генерируется одноразовый proof. Украденный токен без соответствующего ключа не работает — это защита от перехвата и переиспользования токена.

Как работает step-up подтверждение через CIBA?

OpenID CIBA позволяет агенту инициировать запрос на подтверждение, пользователь получает уведомление прямо в чате и подтверждает действие. После этого агент получает короткоживущий токен — в примере на 120 секунд — и выполняет операцию.

Защищает ли RLS от prompt injection?

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

Можно ли использовать Keycloak вместо issuerd?

Да. Keycloak версии 26.4 и выше поддерживает тот же набор стандартов: Token Exchange, DPoP и CIBA. Схема авторизации остаётся идентичной, меняется только конфигурация IdP.

Источники