1. Навіщо взагалі думати про права в ChatGPT‑App (і чим тут особливий ризик)
У «звичайному» вебзастосунку між користувачем і вашою базою даних є лише кілька шарів: фронтенд, API, БД. У ChatGPT‑App між користувачем і API з’являється ще один активний учасник — LLM. І це не просто «текстовий фільтр», а сутність, яка:
- сама обирає, які інструменти викликати та з якими аргументами;
- може бути обманута prompt‑інʼєкцією у даних;
- може «переплутати» інструменти або вигадати аргументи, яких ви не очікували.
Якщо дати LLM надто багато повноважень, ви отримаєте класичну проблему Confused Deputy: модель сумлінно робить те, що, як їй здається, просить користувач або текст у документах, але водночас викликає delete_all_orders замість get_last_order.
Тому наша мета:
- Мінімізувати права auth_tokenʼів (які дані та дії взагалі доступні).
- Обмежити перелік інструментів, доступних моделі в конкретному сценарії.
- Додати людський контроль там, де наслідки особливо критичні.
І все це треба зробити без параної та тотальних заборон. Інакше застосунок стане непридатним. Баланс між зручністю й безпекою — головне завдання цього модуля.
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‑сервер, ми розглядаємо як «запит від користувача, сформульований моделлю». А вирішувати, чи справді можна виконати операцію, — обов’язок вашого бекенд‑коду, а не промпта.
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 — і далі додаєте цей контекст до всіх викликів інструментів.
Далі в обробнику інструмента:
// 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, ви показуєте користувачу модальне вікно зі зрозумілим формулюванням. І лише після натискання «Підтвердити» реально викликаєте інструмент.
Приклад компонента у віджеті (спрощено):
// 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 у токені. Друга велика вісь — сегментація мережі та сервісів.
Ідеальна картина:
- у вас є рівно один публічний вхід у бекенд — MCP Gateway/Edge API;
- усе, що зберігає персональні дані (PII) і гроші, живе в приватній мережі/VPC та доступне лише через цей шлюз;
- вихідний трафік із бекенду обмежений списком дозволених доменів (allowlist: платіжка, CRM, свої мікросервіси).
Схематично:
flowchart LR ChatGPT -- HTTPS --> Edge[API Gateway / MCP Endpoint] Edge -- private network --> MCP[MCP server] MCP -- private --> DB[(БД з PII)] MCP -- private --> SVC[Internal microservices] MCP -- HTTPS (allow) --> Stripe[Payments API]
Тут важливі кілька правил:
- БД і внутрішні сервіси не виставлені напряму в інтернет. Прямий доступ до них — лише з private‑мережі й тільки з тих сервісів, яким це справді потрібно.
- Edge/Gateway виконує автентифікацію та обмеження частоти запитів (rate limiting). Саме він перевіряє токен і scopes, відсікає надто часті запити та пише основні журнали аудиту.
- Egress‑контроль. MCP‑сервер не повинен уміти ходити будь‑куди в інтернет (SSRF‑атаки, витоки даних). Список зовнішніх хостів краще явно обмежити.
На практиці, якщо ви розгортаєте MCP на Vercel, Render або в Kubernetes‑кластері, частину цих речей не завжди можна налаштувати вручну. Але навіть тоді варто розділяти:
- окремі проєкти/кластери для dev/staging/prod;
- різні змінні середовища й ключі для кожного середовища;
- окремий «edge»‑сервіс (HTTP‑обгортка MCP) і окремі приватні сервіси.
Підсумок: у нас уже є дві осі захисту — права в токені (scopes) і мережеві межі. Додаймо до них ще одну: багатокористувацькість (multi‑tenant), коли один і той самий App обслуговує кілька організацій.
7. Multi‑tenant / організаційний контекст
Досі ми подумки працювали з одним користувачем. Але багато ChatGPT‑застосунків — багатоорендні (multi‑tenant): один і той самий App обслуговує десятки компаній. GiftGenius легко перетворити на B2B‑сервіс для корпорацій: у кожного відділу — свої каталоги, бюджети, замовлення.
Що таке tenant і де його брати
Tenant — це зазвичай:
- організація/компанія (Acme Corp);
- робочий простір (workspace);
- іноді проєкт або середовище.
Головна властивість: дані одного тенанта не мають бути видимі іншому.
В 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 із контексту.
Приклад «ізольованого» шляху для створення замовлення
Спробуймо простежити один сценарій від початку до кінця.
- Користувач пише: «Оформи замовлення на цей набір на бюджет 50 $».
- Модель вирішує, що потрібно викликати giftgenius.create_order з аргументами { productId, budget, ... }.
- ChatGPT перевіряє: чи є в App інструмент create_order і які для нього прописані scopes та securitySchemes. Далі розуміє, що потрібен orders:write.
- Якщо токен уже є і містить orders:write, запит іде далі. Якщо ні — ChatGPT ініціює OAuth‑авторизацію із запитом потрібного scope.
- MCP Gateway приймає запит, перевіряє токен, формує RequestContext з userId=123, tenantId="acme", scopes=["catalog:read","orders:write",...].
- createOrder усередині MCP:
- робить requireScope(ctx, "orders:write");
- через enforceTenant фіксує tenant;
- створює замовлення тільки в межах tenantId="acme".
- Якщо при цьому замовлення вимагає миттєвої оплати, модель або сам бекенд далі ініціюють 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. У реальному продакшні так робити не можна: увесь доступ має йти через один захищений gateway/proxy, а БД — жити в приватній мережі. Інакше будь‑який знайдений уразливий endpoint або уразливий вебхук — пряма дорога до даних.
Помилка №6: Використовувати ті самі scopes/секрети в dev і prod.
Улюблений спосіб випадково видалити prod‑дані під час демонстрації локального dev‑середовища. Для кожного середовища мають бути свої ключі, scopes і БД. Навіть якщо хтось отримає доступ до dev‑токена, він не зможе нашкодити prod‑даним.
Помилка №7: Небажання «відмовляти моделі».
Інколи розробники хвилюються: «Якщо я часто повертатиму помилки insufficient_scope або forbidden, модель працюватиме гірше». Насправді це нормальна й очікувана поведінка: модель поступово «вчиться», які дії їй доступні, а які потребують додаткових прав або підтвердження. Набагато гірше, якщо вона «успішно» робить те, чого робити не повинна, — наприклад, проводить другий платіж.