1. 为什么在 ChatGPT‑App 中要认真对待权限(风险何在)
在“传统”Web 应用中,用户与数据库之间通常只有前端、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、租户隔离、输入校验。
- 存储与外部 API:最后一道防线(数据库层面的限制、外部服务账户的权限)。
关键点:LLM 不是权限的来源。所有进入 MCP 服务器的内容,都应视为“由模型代用户表述的请求”。是否真正有权进行该操作,必须由你的后端代码负责判断,而不是靠 prompt。
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. 为 GiftGenius 设计 scopes
以我们的教学项目 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。粒度越细,后续只撤销某一项权限就越容易,不会牵一发而动全身。
scope 类型:read vs write vs critical
建议为 scopes 进行类别标注:
- 安全类(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}`);
}
}
假设你在 MCP Gateway 验证令牌后构建 RequestContext:解码 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");
// 后续:创建订单的业务逻辑
}
这样,即使模型在你未预期的 UX 场景中突然调用了 createOrder,没有 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,若权限不足会触发授权流程(或告知用户);
- 命令式:你的代码在实际动作前再次校验。
若存在令牌但缺少所需 scopes,服务器应返回带有 WWW-Authenticate 的错误: Bearer error="insufficient_scope", scope="orders:write" ——ChatGPT 随后可以向用户请求提升权限(step‑up authorization)。
Insight
官方示例里使用了 securitySchemes。它并未以示例中的形式被官方规范正式采纳。因此需要将其标注为对官方协议的扩展——放入 _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。对于这些工具,最好:
- 不要将其纳入普通 App 的工具清单;
- 在单独的代理/编排器中配置;
- 用独立的 scopes 甚至独立的授权域进行保护。
如果只是把它们加入 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 的 DB)] MCP -- private --> SVC[Internal microservices] MCP -- HTTPS (allow) --> Stripe[Payments API]
关键规则:
- 数据库与内部服务不直接暴露在互联网上。仅允许从私有网络访问,且只对确有需要的服务开放。
- Edge/Gateway 负责认证与限流。它检查令牌与 scopes,限制过于频繁的请求,并记录主要审计日志。
- 出站(egress)控制。MCP 服务器不应能访问互联网上的任意 URL(防 SSRF 与数据外泄)。应显式限制外部主机清单。
在实际部署中,如果你将 MCP 部署在 Vercel、Render 或 Kubernetes 集群上,部分设置不一定能手动调整,但仍可以做到:
- 为 dev/staging/prod 分离项目/集群;
- 为每个环境使用不同的环境变量与密钥;
- 将“edge”服务(MCP 的 HTTP 包装)与私有服务分离部署。
至此,我们已有两条防线:令牌上的 scopes 与网络边界。再加上一条——多租户(同一 App 服务多家组织)。
7. 多租户/组织上下文
此前我们一直以单个用户为例。许多 ChatGPT 应用其实是多租户(multi‑tenant)的:同一 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 或攻击者尝试“塞入”别的租户,也不会得逞。
数据库层面的租户隔离
架构上常见做法:
- 每个租户独立数据库;
- 独立 schema;
- 单库,但每张表都有 tenant_id 并进行严格过滤。
无论采用哪种方案,黄金法则只有一个:任何数据库查询都必须带有 tenant_id (来自上下文)的过滤条件。 在 RAG/向量检索中尤为重要:如果忘了按租户过滤,模型可能会在其他组织的文档中检索。
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 配置中,定义 App、列出工具以及其注解(read‑only、consequential、confirmation‑flows)。
- 在 MCP 服务器中实现:
- 令牌解析与校验;
- 组装 RequestContext { userId, tenantId, scopes };
- 助手函数 requireScope、enforceTenant 等;
- 所有数据库调用都通过上下文中的 tenantId 进行。
创建订单的“隔离”流程示例
尝试端到端跟踪一个场景:
- 用户输入:“为这个套装下单,预算 50 美元。”
- 模型决定需要调用 giftgenius.create_order,并传入参数 { productId, budget, ... }。
- ChatGPT 检查:App 是否有 create_order 工具,以及它声明了哪些 securitySchemes 与 scopes。得知需要 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 锁定租户;
- 仅在 tenantId="acme" 范围内创建订单。
- 若订单需要即时支付,模型或后端随后触发 charge_customer,其中:
- 该工具在计划中被标注为 confirmationRequired;
- 小部件渲染 ConfirmCharge,要求用户显式确认扣款。
这样就实现了纵深防御:即便 prompt 过宽、出现 prompt 注入或 UX 漏洞,也不会导致失控操作,因为在最底层仍有严格的 scopes 校验、租户隔离以及对关键行为的人为确认。
9. 设计权限与分段时的常见错误
错误 1:使用一个巨大的 scope,比如 app:full_access。
这种方式在演示时方便,但在生产中很危险。一旦令牌泄露,后果全面。你无法只撤销某个操作而不影响其他功能。应按领域与操作类型拆分权限(read/write/critical)。
错误 2:只在“入口”做一次权限检查,而不在工具内部检查。
有时会想:“既然 ChatGPT 已经拿到令牌,它就都有权限了。”随后工具 createOrder 被直接调用,即便该令牌并未获授 orders:write。正确做法是:在每个工具中检查 scopes(或至少对所有会变更状态的操作统一做中间件检查)。
错误 3:不标注高风险工具,也不要求确认。
若工具会扣款、删数据或改变访问控制,它不应在模型看来与 listCatalog 一样“普通”。缺少明确注解与 UX 确认会提高模型“理所当然地”调用它的概率。至少要区分 read‑only 与 destructive 工具,并明确标注后者。
错误 4:信任 tenantId 来自工具入参。
常见反模式:工具 getOrders({ tenantId }),其中 tenantId 由模型给出。如果直接使用,tenantA 的用户只需填入另一个租户的 ID 就可能读取 tenantB 的数据。Tenant 必须来自已验证令牌,并强制应用到所有数据库与外部服务请求中;来自用户/模型的值要么忽略,要么强制校验一致性。
错误 5:MCP/数据库直接对外网开放。
在一些简单原型中,MCP 服务器与数据库以裸露的 HTTP/5432 对外提供服务。在生产环境绝不可如此:所有访问必须经过受保护的 gateway/proxy,而数据库应在私有网络中。否则任何一个有漏洞的端点或有问题的 webhook 都可能成为直达数据的通道。
错误 6:在 dev 与 prod 中使用相同的 scopes/密钥。
这是在本地演示时意外删除生产数据的“热门”方式。不同环境必须使用不同的密钥、scopes 与数据库。即便有人拿到 dev 令牌,也无法危及生产数据。
错误 7:不愿意“对模型说不”。
有的开发者担心:“如果我频繁返回 insufficient_scope 或 forbidden 错误,会不会让模型表现更差?”实践表明这是正常且预期的行为:模型会逐步学会哪些操作可用,哪些需要额外权限或确认。更糟糕的是它“成功地”做了不该做的事——比如重复扣款。
GO TO FULL VERSION