CodeGym /课程 /ChatGPT Apps /访问控制与最小化权限:scopes、分段、按工具授权(per‑tool permissions)

访问控制与最小化权限:scopes、分段、按工具授权(per‑tool permissions)

ChatGPT Apps
第 15 级 , 课程 0
可用

1. 为什么在 ChatGPT‑App 中要认真对待权限(风险何在)

在“传统”Web 应用中,用户与数据库之间通常只有前端、API、数据库这几层。在 ChatGPT‑App 中,用户与 API 之间多了一个主动的参与者——LLM。它不是一个“文本过滤器”,而是一个会:

  • 自行选择要调用哪些工具以及使用哪些参数;
  • 可能被数据中的prompt 注入所欺骗;
  • 可能“混淆”工具,或者编造出你未曾预料的参数。

如果给 LLM 的权限过大,就会遇到典型的 Confused Deputy 问题: 模型会本着“好心”执行其理解中的用户请求或文档内容,但却调用了 delete_all_orders 而不是 get_last_order

因此我们的目标是:

  1. 最小化auth_token 的权限(总体可访问的数据与操作)。
  2. 限制模型在具体场景下可用的工具集合。
  3. 增加在人为后果特别严重的地方的人类确认环节。

同时,不能因为过度担忧而“一刀切”地禁用一切,否则 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:在这里检查令牌,映射 userIdtenantIdscopes 列表,并路由到对应服务。
  • MCP 服务器与微服务:执行工具、对接数据库与外部 API。这里必须进行最严格的校验:scopes、租户隔离、输入校验。
  • 存储与外部 API:最后一道防线(数据库层面的限制、外部服务账户的权限)。

关键点:LLM 不是权限的来源。所有进入 MCP 服务器的内容,都应视为“由模型代用户表述的请求”。是否真正有权进行该操作,必须由你的后端代码负责判断,而不是靠 prompt

3. AuthN vs AuthZ:我们已实现的内容与需要补充的内容

在身份验证模块中,你已经完成了:

  • AuthN(Authentication)——确定用户是谁。通过 OAuth 2.1/PKCE,ChatGPT 从 IdP 获取令牌,随后附加到对 MCP 的调用中。令牌中包含 subuser_id 或类似字段,有时还有 tenant_id
  • 基础 AuthZ——也许你已经能区分 user/admin 角色,并至少校验“是否为用户”或“是否为管理员”。

现在我们要升级:

  • 每个 auth_token 都应携带一组scopes(字符串权限),形如 resource:action,例如 catalog:readorders:writepayments: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 表示领域:catalogrecommendationsorderspaymentsadmin
  • action 表示动作类型:readwrite,有时更具体如 createdeletemanage

GiftGenius 示例:

Scope 授权内容
catalog:read
读取公开的礼品目录
recommendations:read
读取用户的推荐历史
orders:write
创建新订单
orders:read
读取用户的订单历史
payments:create
发起支付 / 结账
catalog:admin
编辑目录(仅限管理 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、校验签名/过期时间,取出 subtenantscope,然后将该上下文附加到所有工具调用上。

接着在工具处理器中:

// 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 确认。

可通过以下方式实现:

  • 在工具上添加注解(例如 readOnlyHintdestructiveHint 等字段);
  • 通过文字描述:“此工具会不可逆地删除订单”;
  • 设置独立的 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 开放。

例如 rebuildSearchIndexsyncCatalogFromERP。对于这些工具,最好:

  • 不要将其纳入普通 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]

关键规则:

  1. 数据库与内部服务不直接暴露在互联网上。仅允许从私有网络访问,且只对确有需要的服务开放。
  2. Edge/Gateway 负责认证与限流。它检查令牌与 scopes,限制过于频繁的请求,并记录主要审计日志。
  3. 出站(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 中(tenantorg_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 请求、校验令牌并调用相应工具的代码。

将上文讨论的内容落地到项目中:

  1. 在 MCP 资源的 OAuth 设置中声明 GiftGenius 的 scopes_supported(如 catalog:readorders:write 等)。
  2. 在 Apps SDK 配置中,定义 App、列出工具以及其注解(read‑only、consequential、confirmation‑flows)。
  3. 在 MCP 服务器中实现:
    • 令牌解析与校验;
    • 组装 RequestContext { userId, tenantId, scopes }
    • 助手函数 requireScopeenforceTenant 等;
    • 所有数据库调用都通过上下文中的 tenantId 进行。

创建订单的“隔离”流程示例

尝试端到端跟踪一个场景:

  1. 用户输入:“为这个套装下单,预算 50 美元。”
  2. 模型决定需要调用 giftgenius.create_order,并传入参数 { productId, budget, ... }
  3. ChatGPT 检查:App 是否有 create_order 工具,以及它声明了哪些 securitySchemes 与 scopes。得知需要 orders:write
  4. 若现有令牌包含 orders:write,则继续;否则 ChatGPT 发起带所需 scope 的 OAuth 授权。
  5. MCP Gateway 接收请求、校验令牌,构建 RequestContextuserId=123tenantId="acme"scopes=["catalog:read","orders:write",...]
  6. MCP 中的 createOrder
    • 执行 requireScope(ctx, "orders:write")
    • 通过 enforceTenant 锁定租户;
    • 仅在 tenantId="acme" 范围内创建订单。
  7. 若订单需要即时支付,模型或后端随后触发 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_scopeforbidden 错误,会不会让模型表现更差?”实践表明这是正常且预期的行为:模型会逐步学会哪些操作可用,哪些需要额外权限或确认。更糟糕的是它“成功地”做了不该做的事——比如重复扣款。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION