1. Зачем вообще думать о правах в ChatGPT‑App (и чем тут особенный риск)
В «обычном» веб‑приложении между пользователем и вашей базой всего пара слоёв: фронтенд, API, БД. В ChatGPT‑App между пользователем и API появляется ещё один активный участник — LLM. И это не просто «текстовый фильтр», а сущность, которая:
- сама выбирает, какие инструменты вызывать и с какими аргументами;
- может быть обманута prompt‑инъекцией в данных;
- может «перепутать» инструменты или придумать аргументы, которых вы не ожидали.
Если дать LLM слишком много полномочий, вы получаете классическую проблему Confused Deputy: модель добросовестно выполняет то, что, как ей кажется, просит пользователь или текст в документах, но при этом вызывает delete_all_orders вместо get_last_order.
Поэтому наша цель:
- Минимизировать права auth_token'ов (какие данные и действия доступны вообще).
- Ограничить, какие инструменты вообще доступны модели в конкретном сценарии.
- Добавить человеческий контроль там, где последствия особенно критичны.
И всё это надо сделать без паранойи и тотального запрета всего, иначе App станет бесполезным. Баланс между удобством и безопасностью — наш главный квест в этом модуле.
2. Модель доступа в экосистеме: кто к чему лезет
Чтобы не запутаться, посмотрим на систему целиком. У нас есть несколько уровней, каждый со своей зоной ответственности и своими правами.
flowchart TD U[Пользователь в ChatGPT] --> C[ChatGPT UI + LLM] C --> A["Ваш App (визуальный план + виджет)"] A --> G[MCP Gateway / API Edge] G --> S[MCP-серверы и микросервисы] S --> D[Базы данных, очереди, внешние API]
Кратко по ролям:
- ChatGPT UI и LLM: управляются OpenAI. Вы задаёте им инструкции (system‑prompt, tool descriptions), но не контролируете внутренние токены и права платформы.
- Ваш App (план, tools, виджет): вы решаете, какие инструменты доступны, как они описаны, какие UX‑подтверждения нужны, какие данные виджет может отображать.
- MCP Gateway / API Edge: здесь происходит проверка токена, маппинг userId, tenantId, списка scopes и маршрутизация на нужный сервис.
- MCP‑серверы и микросервисы: выполняют инструменты, делают запросы к БД и внешним API. Здесь должны быть максимально строгие проверки: scopes, tenant‑изоляция, валидация входных данных.
- Хранилища и внешние API: последняя линия обороны (ограничения на уровне БД, прав учёток внешних сервисов).
Ключевая мысль: LLM не является источником прав доступа. Всё, что приходит в MCP‑сервер, мы рассматриваем как «запрос от пользователя, сформулированный моделью». Решать, можно ли действительно выполнять операцию, — обязанность вашего backend‑кода, а не промпта.
3. AuthN vs AuthZ: что мы уже умеем и что добавляем
В модуле про аутентификацию вы уже делали:
- AuthN (Authentication) — выясняли, кто этот пользователь. Через OAuth 2.1/PKCE ChatGPT получал от IdP токен, который потом прикладывался к вызовам MCP. В нём были sub, user_id или аналог, иногда tenant_id.
- Базовый AuthZ — возможно, вы уже умели разделять роли user/admin и проверяли хотя бы «юзер ли это» или «админ».
Теперь усложняем картину:
- каждый auth_token должен нести набор scopes — строковые права вида resource:action, например catalog:read, orders:write, payments:create;
- ваш MCP‑сервер должен проверять соответствие этих scopes каждому действию, а не только «один раз на входе»;
- разные инструменты и даже разные операции внутри одного инструмента могут требовать разных scopes.
В терминах OAuth 2.1 ChatGPT — это «public client», MCP — «resource server», а ваш OAuth‑сервер знает, какие scopes поддерживаются и что именно они означают. Метаданные MCP‑ресурса обычно объявляют scopes_supported, чтобы ChatGPT мог запрашивать у пользователя ровно нужные разрешения.
4. Проектируем scopes для GiftGenius
Возьмём наш учебный GiftGenius и посмотрим, какие у него есть домены данных и действий. Из функционала у нас что‑то вроде:
- просмотр каталога и карточек подарков;
- рекомендации на основе истории;
- создание заказов;
- запуск чекаута / списание средств;
- админское редактирование каталога.
Вместо того чтобы делать один всесильный giftgenius:full_access, лучше разложить это на разумные scopes.
Соглашение по именованию: resource:action
Хорошо работает стратегия resource:action, где:
- resource описывает домен: catalog, recommendations, orders, payments, admin.
- action описывает тип действия: read, write, иногда более конкретно: create, delete, manage.
Пример для GiftGenius:
| Scope | Что разрешает |
|---|---|
|
Читать публичный каталог подарков |
|
Читать историю рекомендаций пользователя |
|
Создавать новые заказы |
|
Читать историю заказов пользователя |
|
Инициировать платёж / чек-аут |
|
Редактировать каталог (только для админ‑UI/поддержки) |
Обычный пользователь GiftGenius потребует что‑то вроде (перечисляются через пробел): catalog:read recommendations:read orders:write orders:read payments:create. Администратору добавим catalog:admin.
Важно: не делаем универсальный *:* или admin:all. Чем более гранулярно, тем проще потом отозвать конкретное право, не ломая всё приложение целиком.
Типы scopes: read vs write vs critical
Полезно мысленно пометить scopes категориями:
- безопасные (read): не меняют состояние, максимум раскрывают данные;
- мутирующие (write): создают/меняют сущности, накручивают стойки, но не трогают деньги и не удаляют всё подряд;
- критичные (critical): платежи, удаление аккаунта, массовое удаление данных.
Для критичных прав можно применять повышенный контроль:
- выдавать их минимальному числу пользователей;
- запрашивать у пользователя отдельное согласие в UI ChatGPT при выдаче токена;
- на стороне MCP требовать дополнительного подтверждения (например, одноразовый PIN, но это уже продвинутые сценарии).
Scopes в коде: RequestContext и requireScope
На уровне MCP удобно завести единый тип контекста:
// mcp/context.ts
export interface RequestContext {
userId: string; // кто
tenantId: string; // в рамках какой организации
scopes: string[]; // какие права выданы токену
}
// Простой хелпер для проверки прав
export function requireScope(
ctx: RequestContext,
needed: string
) {
if (!ctx.scopes.includes(needed)) {
throw new Error(`Missing scope: ${needed}`);
}
}
Предполагается, что RequestContext вы формируете в MCP Gateway после валидации токена: раскодировали JWT, проверили подпись/истечение, вытащили sub, tenant, scope — и дальше прикладываете этот контекст ко всем вызовам инструментов.
Дальше в tool‑хендлере:
// mcp/tools/createOrder.ts
import { requireScope, RequestContext } from "../context";
export async function createOrder(
input: CreateOrderInput,
ctx: RequestContext
) {
requireScope(ctx, "orders:write");
// дальше - логика создания заказа
}
Теперь, даже если модель внезапно вызовет createOrder там, где по UX вы этого не ожидали, без orders:write инструмент просто не выполнится.
securitySchemes на уровне инструмента
MCP‑спецификация позволяет каждому инструменту указывать, какие схемы авторизации и scopes ему нужны. В официальных примерах securitySchemes прикручиваются прямо к описанию инструмента.
Условный пример:
// mcp/server.ts
server.registerTool(
"createOrder",
{
title: "Create order",
description: "Creates a new order for current user",
inputSchema: {/*...*/},
securitySchemes: [
{ type: "oauth2", scopes: ["orders:write"] }
]
},
async ({ input }, ctx: RequestContext) => {
requireScope(ctx, "orders:write");
// ...
}
);
Здесь два уровня защиты:
- декларативный: ChatGPT знает, что для этого инструмента нужен orders:write и при отсутствии прав будет инициировать auth‑флоу (или сообщит об этом пользователю);
- императивный: ваш код ещё раз всё проверяет перед реальным действием.
Если токен есть, но не хватает scopes, сервер должен вернуть ошибку с WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write" — и ChatGPT сможет запросить у пользователя расширение прав (step‑up authorization).
Insight
В официальных примерах используется securitySchemes. Она не была утверждена в официальной спецификации в том виде, в котором написана в примерах ChatGPT Apps SDK. Поэтому её нужно помечать как расширение официального протокола — оборачивать в _meta. Рабочий вариант примера выше:
// mcp/server.ts
server.registerTool(
"createOrder",
{
title: "Create order",
description: "Creates a new order for current user",
inputSchema: {/*...*/},
_meta: { // вот так
securitySchemes: [
{ type: "oauth2", scopes: ["orders:write"] }
]
}
},
async ({ input }, ctx: RequestContext) => {
requireScope(ctx, "orders:write");
// ...
}
);
5. Per‑tool permissions и «опасные» инструменты
Scopes отвечают на вопрос «что в принципе может делать этот auth_token». Но внутри токена есть ещё и список инструментов, которыми модель может пользоваться. Их тоже надо проектировать аккуратно.
Классификация инструментов
Условно делим инструменты на:
- информационные (informational / read‑only): читают данные, строят отчёты, считают что‑то без побочных эффектов;
- действующие (consequential): изменяют состояние, списывают деньги, что‑то удаляют.
В документации по ChatGPT Apps прямо рекомендуют для read‑only инструментов явно помечать, что они безопасны, а для опасных — описывать последствия и включать дополнительные UX‑подтверждения.
Это можно делать:
- через аннотации к инструменту (условные поля вроде readOnlyHint, destructiveHint);
- через текстовое описание: «Этот инструмент удаляет заказы безвозвратно»;
- через отдельный флаг confirmation_required, который ваш App‑план использует, чтобы вставить шаг‑подтверждение в диалог.
UX‑подтверждения для критичных действий
Например, у GiftGenius есть инструмент chargeCustomer (инициирует списание средств). Вы, разумеется, не хотите, чтобы модель вызывала его без согласия пользователя.
Как это может выглядеть на уровне плана App:
// app/plan/tools.ts (псевдокод)
export const tools = [
{
name: "giftgenius.list_catalog",
description: "Показать каталог подарков",
annotations: { readOnlyHint: true }
},
{
name: "giftgenius.create_order",
description: "Создать заказ без оплаты",
annotations: { consequential: true }
},
{
name: "giftgenius.charge_customer",
description: "Списать деньги за заказ",
annotations: {
consequential: true,
destructiveHint: true,
confirmationRequired: {
title: "Списать деньги с карты?",
message: "Будет проведён платёж по заказу N."
}
}
}
];
Конкретные имена полей зависят от версии SDK, но сама идея совпадает с рекомендациями: read‑only инструменты помечаем как безопасные, опасные — как требующие явного подтверждения и хорошего объяснения в описании.
Дальше ваш виджет может отреагировать: если модель предлагает вызвать charge_customer, вы показываете пользователю модальное окно с понятной формулировкой и только после клика «Подтвердить» реально делаете tool‑call.
Пример компонентки в виджете (упрощённо):
// widget/components/ConfirmCharge.tsx
export function ConfirmCharge(props: {
orderId: string;
onConfirm: () => void;
}) {
return (
<div>
<p>Списать деньги за заказ {props.orderId}?</p>
<button onClick={props.onConfirm}>
Да, подтвердить оплату
</button>
</div>
);
}
Модель инициирует идею «пора платить», но финальную кнопку нажимает человек. Это и есть human‑in‑the‑loop, который так любят безопасники.
Инструменты только для агентов/бэк‑офиса
Ещё один частый кейс: у вас есть инструменты, которыми могут пользоваться только агенты (в смысле Agents SDK) или внутренние админки, но не «обычный» ChatGPT App пользователя.
Например, rebuildSearchIndex или syncCatalogFromERP. Их лучше:
- не включать в общий список tools для обычного App;
- настроить в отдельном агенте/оркестраторе;
- защитить отдельными scopes и, возможно, отдельным Auth‑контуром.
Если просто добавить их в список доступных инструментов App, вы повышаете риск, что модель внезапно решит: «А давай я пересоздам индекс прямо сейчас, вдруг поможет найти подарок».
6. Сетевая сегментация и границы доверия
Права — это не только scopes на токене. Вторая крупная ось — сегментация сети и сервисов.
Идеальная картина:
- у вас есть ровно один публичный вход в backend — MCP Gateway/Edge API;
- всё, что хранит PII и деньги, живёт в приватной сети/VPC и доступно только через этот шлюз;
- outbound‑трафик с backend ограничен списком разрешённых доменов (allowlist: платёжка, CRM, свои микросервисы).
Схематично:
flowchart LR ChatGPT -- HTTPS --> Edge[API Gateway / MCP Endpoint] Edge -- private network --> MCP[MCP server] MCP -- private --> DB[(DB с PII)] MCP -- private --> SVC[Internal microservices] MCP -- HTTPS (allow) --> Stripe[Payments API]
Здесь важны несколько правил:
- DB и внутренние сервисы не выставлены напрямую в интернет. Прямой доступ к ним только из private‑сети и только с тех сервисов, которым это действительно нужно.
- Edge/Gateway проводит auth и rate‑limiting. Именно он проверяет токен и scopes, режет слишком частые запросы и пишет основные audit‑логи.
- Egress‑контроль. MCP‑сервер не должен уметь ходить по любым URL из интернета (SSRF‑атаки, утечки данных). Список внешних хостов лучше явно ограничить.
На практике, если вы деплоите MCP на Vercel, Render или в Kubernetes‑кластере, часть этих вещей руками не настраивается, но даже там можно разделять:
- отдельные проекты/кластеры для dev/staging/prod;
- разные переменные окружения и ключи для каждого окружения;
- отдельный «edge» сервис (HTTP‑обёртка MCP) и отдельные приватные сервисы.
Итого, у нас уже есть две оси защиты: права на токене (scopes) и границы сети. Добавим к ним ещё одну — многотенантность, когда одно и то же App обслуживает несколько организаций.
7. Multi‑tenant / организационный контекст
До сих пор мы мысленно работали с одним пользователем. Но многие ChatGPT‑приложения — мультиарендные (multi‑tenant): один и тот же App обслуживает десятки компаний. GiftGenius легко превратить в B2B‑сервис для корпораций: у каждого отдела свои каталоги, бюджеты, заказы.
Что такое tenant и где его брать
Tenant — это обычно:
- организация/компания (Acme Corp);
- рабочее пространство (workspace);
- иногда проект или среда.
Главное свойство: данные одного tenant’а не должны быть видны другому.
В auth‑флоу tenant обычно кладут в:
- claim токена (tenant, org_id);
- отдельный параметр в запросе авторизации (но это менее надёжно, чем claim, подписанный IdP).
Важно: доверяем только tenantId из проверенного токена, а не из аргументов инструментов. Если модель сгенерирует {"tenantId": "acme"}, а в токене у пользователя tenantId: "globex", это должно восприниматься как попытка взлома.
Tenant в контексте запроса
Добавим tenantId в наш RequestContext (мы уже это сделали выше) и не будем позволять его переопределять из входных данных.
Базовая проверка:
// mcp/tenant.ts
import { RequestContext } from "./context";
export function enforceTenant<TInput>(
input: TInput & { tenantId?: string },
ctx: RequestContext
) {
if (input.tenantId && input.tenantId !== ctx.tenantId) {
throw new Error("Tenant mismatch");
}
return { ...input, tenantId: ctx.tenantId };
}
Дальше в инструменте:
// mcp/tools/listOrders.ts
export async function listOrders(
input: { limit?: number; tenantId?: string },
ctx: RequestContext
) {
const safe = enforceTenant(input, ctx);
return db.order.findMany({
where: { tenantId: safe.tenantId },
take: safe.limit ?? 20
});
}
Мы игнорируем tenant из аргументов и жёстко подставляем его из контекста. Так даже если LLM или злоумышленник пытается «подсунуть» чужой tenant, ничего не выйдет.
Tenant‑изоляция на уровне БД
Архитектурно есть разные варианты:
- отдельная БД на tenant;
- отдельные схемы;
- одна БД с tenant_id в каждой таблице и строгой фильтрацией.
Какой бы вариант вы ни выбрали, золотое правило одно: ни один запрос к БД не должен выполняться без фильтрации по tenant_id из контекста. Особенно это важно в RAG/векторном поиске: если забыть фильтр по tenant, модель может начать искать по документам чужих организаций.
8. Как это связывается с нашим Next.js/Apps SDK приложением
Давайте теперь соберём всё это вместе и посмотрим, как scopes, tenant и сетевые границы приземляются в наш Next.js‑проект на Apps SDK. Давайте добавим больше конкретики и посмотрим на код Next.js и Apps SDK.
Где живут scopes и tenant в нашем проекте
Типичный расклад для учебного проекта:
- В Next.js‑приложении (Apps SDK) у вас есть конфиг App/коннектора и странички для OAuth callback’ов.
- В MCP‑сервере — код, который принимает HTTP/SSE‑запросы из ChatGPT, проверяет токен и вызывает нужный инструмент.
Переносим туда всё, что обсудили:
- В OAuth‑настройках MCP‑ресурса объявляем scopes_supported для GiftGenius (catalog:read, orders:write, и т.п.).
- В Apps SDK‑конфиге описываем App с перечислением инструментов и их аннотациями (read‑only, consequential, confirmation‑flows).
- В MCP‑сервере реализуем:
- парсинг и проверку токена;
- формирование RequestContext { userId, tenantId, scopes };
- хелперы requireScope, enforceTenant и т.п.;
- вызовы БД всегда через tenantId из контекста.
Пример «изолированного» пути для создания заказа
Попробуем проследить один сценарий end‑to‑end.
- Пользователь пишет: «Оформи заказ на этот набор для бюджета 50$».
- Модель решает, что нужно вызвать giftgenius.create_order с аргументами { productId, budget, ... }.
- ChatGPT проверяет: есть ли у App инструмент create_order, какие для него scopes и securitySchemes прописаны. Понимает, что нужен orders:write.
- Если токен уже есть и содержит orders:write, запрос идёт дальше; если нет — ChatGPT инициирует OAuth‑авторизацию с запросом нужного scope.
- MCP Gateway принимает запрос, проверяет токен, формирует RequestContext c userId=123, tenantId="acme", scopes=["catalog:read","orders:write",...].
- createOrder внутри MCP:
- делает requireScope(ctx, "orders:write");
- через enforceTenant фиксирует tenant;
- создаёт заказ только в рамках tenantId="acme".
- Если при этом заказ требует мгновенной оплаты, модель или сам backend далее инициируют charge_customer, где:
- инструмент в плане помечен как confirmationRequired;
- виджет рендерит ConfirmCharge и просит пользователя явно подтвердить списание.
Так мы получаем глубинную защиту: слишком широкие промпты, prompt‑инъекции или даже баги в UX не приведут к неконтролируемым действиям, потому что внизу пирамиды всё равно стоят строгие проверки scopes, tenant и ручные подтверждения для критичных действий.
9. Типичные ошибки при проектировании прав и сегментации
Ошибка №1: Один толстый scope вроде app:full_access.
Такой подход удобен на демо, но опасен в продакшене. Потеряли один токен — потеряли всё. Нельзя отозвать или запретить одну операцию, не сломав остальные. Разбивайте права по доменам и типам операций (read/write/critical).
Ошибка №2: Проверять права только «на входе» и не проверять внутри инструментов.
Иногда делают так: «раз ChatGPT получил токен, то он уже всё умеет». А дальше инструмент createOrder просто вызывается, даже если для этого конкретного токена не было выдано orders:write. Правильный подход — проверять scopes в каждом инструменте (или хотя бы централизованным middleware для всех мутирующих операций).
Ошибка №3: Не маркировать опасные инструменты и не требовать подтверждения.
Если инструмент списывает деньги, удаляет данные или меняет доступы, он не должен выглядеть для модели так же, как listCatalog. Отсутствие явных аннотаций и UX‑подтверждений повышает шанс, что модель вызовет его «просто потому что логично». Как минимум, разделяйте read‑only и destructive‑инструменты и явно помечайте вторые.
Ошибка №4: Доверять tenantId из аргументов инструмента.
Очень частый анти‑паттерн: инструмент getOrders({ tenantId }), где tenantId приходит от модели. Если использовать его как есть, пользователь из tenantA может получить доступ к данным tenantB, просто указав другой идентификатор. Tenant должен приходить из проверенного токена и навязываться всем запросам к БД и внешним сервисам, а пользовательские значения либо игнорируются, либо валидируются на совпадение.
Ошибка №5: MCP/БД доступны из интернета напрямую.
Иногда в простых прототипах MCP‑сервер и БД торчат в интернет на голом HTTP/5432. В prod‑мире так делать нельзя: весь доступ должен идти через один защищённый gateway/proxy, а БД жить в приватной сети. Иначе любой найденный уязвимый endpoint или дырявый вебхук — прямая дорога к данным.
Ошибка №6: Использование одних и тех же scopes/секретов в dev и prod.
Любимый способ устроить себе внезапное удаление прод‑данных во время демонстрации локального dev‑окружения. Для каждого окружения должны быть свои ключи, scopes и БД. Даже если кто‑то получит доступ к dev‑токену, он не сможет навредить prod‑данным.
Ошибка №7: Нежелание «отказывать модели».
Иногда разработчики переживают: «Если я буду часто возвращать ошибки insufficient_scope или forbidden, модель будет работать хуже». На практике это нормальное и ожидаемое поведение: модель учится, какие действия ей доступны, а какие требуют дополнительных прав или подтверждения. Хуже, если она «успешно» делает то, что делать не должна — например, проводит второй платёж.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ