1. なぜ ChatGPT‑App で権限設計を考える必要があるのか(特有のリスクは何か)
「通常」の Web アプリでは、ユーザーとデータベースの間にはせいぜいフロントエンド、API、DB の数層しかありません。ChatGPT‑App では、ユーザーと API の間にもう 1 人のアクティブな参加者 — LLM — が現れます。ただの「テキストフィルタ」ではなく、次のような性質を持つ主体です。
- 自分で判断してどのツールをどの引数で呼ぶかを選ぶ;
- データ内の プロンプトインジェクション によってだまされる可能性がある;
- ツールを取り違えたり、想定外の引数をでっち上げることがある。
LLM に権限を与えすぎると、典型的な Confused Deputy 問題が起こります。 モデルはユーザーや文書中のテキストが求めていると思ったことを善意で実行しますが、その過程で get_last_order ではなく delete_all_orders を呼んでしまうかもしれません。
そこで私たちのゴールは次のとおりです。
- 最小化 — 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 サーバーとマイクロサービス: ツールを実行し、DB や外部 API へリクエストします。ここでは scopes、テナント隔離、入力バリデーションなど、可能な限り厳格な検証が必要です。
- ストレージと外部 API: 最後の防衛ライン(DB レベルの制限、外部サービスのアカウント権限の最小化)。
重要なポイント: LLM はアクセス権の源ではありません。 MCP サーバーに届くものは、すべて「モデルが整形したユーザーからのリクエスト」と見なします。実際に操作を許可すべきかどうかを決めるのは、プロンプトではなくあなたのバックエンドコードの責務です。
3. AuthN と 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 を 各操作ごとにチェックすべきで、単なる入り口チェックでは不十分です。
- ツールごと、さらには 1 つのツール内の操作ごとに必要な scope が異なることもあります。
OAuth 2.1 の用語で言えば、ChatGPT は「public client」、MCP は「resource server」、そしてあなたの OAuth サーバーがサポートする scope とその意味を知っています。MCP リソースのメタデータでは通常 scopes_supported を宣言し、ChatGPT がユーザーに必要な許可だけを要求できるようにします。
4. GiftGenius のための scopes 設計
学習用の GiftGenius を例に、どのデータドメインと操作があるかを見てみます。機能は次のようなものです。
- カタログとギフトのカードの閲覧;
- 履歴に基づくレコメンデーション;
- 注文の作成;
- チェックアウト/決済の開始;
- カタログの管理者編集。
giftgenius:full_access のような万能権限を 1 つ作るのではなく、合理的な scopes に分割する方が良いです。
命名規則: resource:action
有効なのは resource:action という戦略です。ここで:
- resource はドメインを表します: catalog、recommendations、orders、payments、admin。
- action は操作の種類を表します: read、write、場合によってはより具体的に create、delete、manage など。
GiftGenius の例:
| Scope | 許可する内容 |
|---|---|
|
ギフトの公開カタログを読み取り |
|
ユーザーのレコメンデーション履歴を読み取り |
|
新規注文の作成 |
|
ユーザーの注文履歴の読み取り |
|
支払い/チェックアウトを開始 |
|
カタログの編集(管理 UI/サポート専用) |
通常の GiftGenius ユーザーには(スペース区切りで)次のような scope が必要です: catalog:read recommendations:read orders:write orders:read payments:create。 管理者には catalog:admin を追加します。
重要: 汎用の *:* や admin:all は作らないでください。粒度が細かいほど、アプリ全体を壊さずに特定の権限だけを取り消すのが容易になります。
scope のタイプ: read vs write vs critical
scope を次のように分類して考えると有益です。
- 安全(read): 状態を変更せず、最大でもデータを開示するだけ。
- 変更(write): エンティティの作成/更新を行うが、お金を扱ったり無差別に削除したりはしない。
- 重大(critical): 支払い、アカウント削除、大量データ削除など。
重大な権限には強化されたコントロールを適用できます。
- 付与対象を最小限のユーザーに限定する;
- トークン発行時に ChatGPT の UI で追加の同意をユーザーに求める;
- 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");
// 以下、注文作成のロジック
}
これで、UX 上は想定していない場所でモデルが突然 createOrder を呼び出しても、orders:write がなければツールは実行されません。
ツールレベルの securitySchemes
MCP 仕様では、各ツールに対して必要な認可スキームや scope を示せます。公式の例では 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");
// ...
}
);
ここでは 2 層の防御があります。
- 宣言的: ChatGPT はこのツールに orders:write が必要だと理解し、権限がなければ認可フローを開始する(またはユーザーに不足を伝える)。
- 命令的: 実際の処理の直前に、あなたのコードが再度すべてを検証します。
トークンはあるが必要な scope が足りない場合、サーバーは 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 と「危険な」ツール
scope は「この auth_token が原則として何をできるか」に答えます。しかしトークン内には、モデルが利用できるツールのリストもあります。これも慎重に設計しましょう。
ツールの分類
ツールを大まかに次の 2 種類に分けます。
- 情報系(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 などです。これらは次のように扱うのがよいでしょう。
- 一般 App の tools 一覧には含めない;
- 別のエージェント/オーケストレーターに設定する;
- 専用の scope や、場合によっては別の認証境界で防御する。
単に一般 App の利用可能ツールに追加すると、モデルが「今すぐインデックスを再生成すればギフトが見つかるかも」と突然決断するリスクが高まります。
6. ネットワーク分割と信頼境界
権限は scopes だけではありません。もう 1 つの大きな軸が ネットワークとサービスの分割です。
理想像:
- バックエンドへの公開入り口は 1 つだけ — 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 を含む DB)] MCP -- private --> SVC[Internal microservices] MCP -- HTTPS (allow) --> Stripe[Payments API]
ここで重要なルール:
- DB と内部サービスはインターネットに直接公開しない。 プライベートネットワーク内から、かつ本当に必要なサービスだけが直接アクセスできます。
- Edge/Gateway が認証とレート制限を行う。 トークンと scope を検証し、過剰なリクエストを制限し、主要な監査ログを記録します。
- Egress 制御。 MCP サーバーがインターネット上の任意の URL にアクセスできるべきではありません(SSRF 攻撃、データ漏えい)。外部ホストは明示的に制限するのが望ましいです。
実際には MCP を Vercel、Render、あるいは Kubernetes クラスタにデプロイする場合、これらの一部は手動設定できませんが、それでも次のような分離は可能です。
- dev/staging/prod ごとに別プロジェクト/クラスタ;
- 環境ごとに異なる環境変数と鍵;
- 「edge」サービス(MCP の HTTP ラッパー)とプライベートサービスを分離。
ここまでで 2 本の防御軸(トークン上の scopes とネットワーク境界)ができました。ここに、同一 App が複数組織に提供される場合のマルチテナント性を加えます。
7. マルチテナント / 組織コンテキスト
ここまでは単一ユーザーを想定していました。しかし多くの ChatGPT アプリはマルチテナントです。同一の App が複数企業に提供されます。GiftGenius は簡単に企業向け B2B サービスになり得ます。部署ごとにカタログ、予算、注文が異なります。
tenant とは何か、どこから得るか
tenant は通常次のいずれかです。
- 組織/企業(Acme Corp);
- ワークスペース(workspace);
- 場合によってはプロジェクトや環境。
本質は、あるテナントのデータが別のテナントに見えてはならないことです。
認可フローでは tenant は通常次のいずれかに入ります。
- トークンの claim(tenant、org_id);
- 認可リクエストの別パラメータ(ただし IdP に署名された claim より信頼性は低い)。
重要: 信頼するのは検証済みトークンの 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 や攻撃者が他人のテナントを「差し込もう」としても効果がありません。
DB レベルでのテナント隔離
アーキテクチャには複数の選択肢があります。
- テナントごとに別 DB;
- 別スキーマ;
- 単一 DB で各テーブルに tenant_id を持たせ、厳格にフィルタリング。
どの方式を選んでも黄金律は同じです。DB へのいかなるクエリも、コンテキストの 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 コールバック用のページがある。
- MCP サーバー側に、ChatGPT からの HTTP/SSE を受け、トークンを検証し、適切なツールを呼ぶコードがある。
ここにこれまでの内容を反映します。
- MCP リソースの OAuth 設定で GiftGenius 用の scopes_supported(catalog:read、orders:write 等)を宣言。
- Apps SDK の設定で、ツールの一覧とそのアノテーション(read‑only、consequential、confirmation‑flows)を記述。
- MCP サーバーでは以下を実装:
- トークンのパースと検証;
- RequestContext の構築({ userId, tenantId, scopes });
- ヘルパー requireScope、enforceTenant 等;
- DB 呼び出しは常にコンテキストの tenantId 経由で行う。
注文作成の「隔離された」経路の例
1 つのシナリオをエンドツーエンドで追ってみます。
- ユーザー: 「このセットで $50 の予算で注文を作って」。
- モデルは giftgenius.create_order を { productId, budget, ... } という引数で呼ぶ必要があると判断。
- ChatGPT は App に create_order ツールがあるか、必要な scope と securitySchemes は何かを確認し、orders:write が必要だと理解。
- すでにトークンがあり、orders:write を含んでいれば先へ。なければ、ChatGPT は必要な scope を要求する OAuth 認可を開始します。
- MCP Gateway がリクエストを受け、トークンを検証して RequestContext を構築: userId=123、 tenantId="acme"、 scopes=["catalog:read","orders:write",...]。
- MCP 内の createOrder は:
- requireScope(ctx, "orders:write") を実行;
- enforceTenant で tenant を固定;
- tenantId="acme" の範囲でのみ注文を作成。
- その注文で即時決済が必要なら、モデルまたはバックエンドが続けて charge_customer を開始します。そこでは:
- ツールが confirmationRequired としてマークされている;
- ウィジェットが ConfirmCharge をレンダーし、ユーザーに明示的な確認を求める。
このように多層防御を実現します。過度に広いプロンプト、プロンプトインジェクション、UX のバグがあっても、最下層で厳格な scope/tenant チェックと重大操作の人手確認が残っているため、無制御な実行にはなりません。
9. 権限設計とセグメンテーションにおける典型的な誤り
誤り 1: 1 つの巨大な scope(例 app:full_access)にする。
デモでは楽でも本番では危険です。トークン 1 本が漏れたら全滅。特定の操作だけを禁止・取り消すことができません。権限はドメインと操作タイプ(read/write/critical)で分割しましょう。
誤り 2: 入り口だけで権限を確認し、ツール内で検証しない。
「ChatGPT がトークンを取れたなら、もう何でもできる」と考えてしまうパターン。結果として、createOrder が、そのトークンに orders:write が付与されていなくても呼ばれてしまうことに。正解は、各ツール(少なくともすべての変更系操作)で scope を検証することです(共通ミドルウェアでも可)。
誤り 3: 危険なツールをマークせず、確認も要求しない。
決済、データ削除、アクセス変更を行うツールは、listCatalog と同列に見えてはいけません。明確なアノテーションや UX 確認がないと、モデルは「論理的だから」と安易に呼びがちです。最低限、read‑only と destructive なツールを分け、後者を明示的にマークしましょう。
誤り 4: ツール引数の tenantId を信用する。
ありがちなアンチパターン: getOrders({ tenantId }) のように、tenantId をモデルから受け取り、そのまま使ってしまうこと。これでは tenantA のユーザーが tenantB のデータにアクセスできてしまいます。tenant は 検証済みトークン から取得し、DB や外部サービスへの全リクエストに強制適用すべきです。ユーザー入力の値は無視するか、一致を検証します。
誤り 5: MCP/DB がインターネットに直接公開されている。
簡易プロトタイプでは MCP サーバーや DB がそのままインターネット(HTTP/5432)に晒されていることがありますが、本番では禁止です。すべてのアクセスは 1 つの保護された gateway/proxy を経由し、DB はプライベートネットワークに置きましょう。脆弱なエンドポイントや穴のある Webhook が見つかれば、データ直行です。
誤り 6: dev と prod で同じ scopes/シークレットを使う。
ローカルのデモ中に本番データをうっかり消す定番のやらかし。環境ごとに鍵、scopes、DB を分けるべきです。仮に dev トークンが漏れても、本番データに被害は及びません。
誤り 7: モデルに「拒否」することをためらう。
insufficient_scope や forbidden を頻繁に返すとモデルの動作が悪化するのでは、と心配する開発者もいます。実際には正常で期待どおりの挙動です。モデルはどの操作が可能で、どれが追加権限や確認を要するかを学習します。むしろ、やってはいけないこと(例: 二重決済)を「成功」させる方がよほど悪いのです。
GO TO FULL VERSION