Чат-интерфейс стал стандартом: пользователь пишет «верни деньги за заказ #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 — это последний рубеж
Начнём с хранилища, потому что какую бы глупость ни совершил агент, запрос в конце концов упрётся в базу.
-- 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).
браузер ──► ChatApp (чат, :5108) ──► IdP (OIDC, :8080)
│
└──► McpServer (только внутр. сеть) ──► PostgreSQL
JWT + DPoP + scope
RLS по sub пользователяНужен только Docker:
git clone https://github.com/issuerd/issuerd.git
cd issuerd/examples/agentic-mcp
docker compose up -dОткрываем http://localhost:5108, логинимся как alice / changeme — и можно ломать.
Если браузера под рукой нет, скрипт прогоняет весь сюжет автоматически: логин, заказы, инъекцию, возврат и три атаки.
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.


