CodeGym /課程 /ChatGPT Apps /存取控制與最小化權限:scopes、網路分段、逐工具權限

存取控制與最小化權限:scopes、網路分段、逐工具權限

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、tenant 隔離、輸入驗證。
  • 儲存體與外部 API:最後一道防線(資料庫層級限制、外部服務帳號的權限)。

關鍵想法:LLM 不是權限的來源。所有送到 MCP 伺服器的請求,我們都視為「由模型代為表述的使用者請求」。是否真的可執行該操作,是你的後端程式碼的責任,而不是 prompt 的責任

3. AuthN 與 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[];      // 權杖擁有哪些權限
}

// 用於檢查權限的簡單 helper
export function requireScope(
  ctx: RequestContext,
  needed: string
) {
  if (!ctx.scopes.includes(needed)) {
    throw new Error(`Missing scope: ${needed}`);
  }
}

假設你會在 MCP Gateway 驗證權杖後組出 RequestContext:解碼 JWT、檢查簽章/有效期、取出 subtenantscope——然後將此脈絡附加到所有工具呼叫上。

接著在 tool handler 裡:

// 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,其寫法在 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 確認。

可透過以下方式:

  • 在工具上加註(如 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,你就向使用者顯示一個語意清楚的對話框,只有在點擊「確認」後才真的進行 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 使用。

例如 rebuildSearchIndexsyncCatalogFromERP。最好:

  • 不要把它們放到一般 App 的工具清單中;
  • 在獨立的代理/協調器中配置;
  • 用獨立的 scopes 甚至獨立的 Auth 邏輯來保護。

若只是把它們加進 App 的可用工具清單,你就提高了模型突然「覺得」重建索引也許能幫助找禮物而直接動手的風險。

6. 網路分段與信任邊界

權限不只在於權杖上的 scopes。另一條重要軸線是網路與服務的分段

理想狀態:

  • 後端只有一個對外入口——MCP Gateway/Edge API;
  • 所有存放 PII 與金流的服務都在私有網路/VPC 中,只能透過此閘道存取;
  • 後端的對外連線(outbound)只允許到白名單域名(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]

幾個關鍵規則:

  1. 資料庫與內部服務不直接暴露在網際網路上。只能從私有網路、且僅限真正需要的服務連入。
  2. Edge/Gateway 負責身分驗證與 rate‑limiting。由它檢查權杖與 scopes、限制過於頻繁的請求,並寫主要的稽核日誌。
  3. Egress 控制。MCP 伺服器不應能任意連往互聯網的任何 URL(避免 SSRF 與資料外洩)。外部主機清單最好明列限定。

實務上,若你把 MCP 部署到 Vercel、Render 或 Kubernetes 叢集,有些設定不一定能手動細調,但依然可以做到:

  • dev/staging/prod 使用獨立專案/叢集;
  • 各環境使用不同的環境變數與金鑰;
  • 獨立的「edge」服務(MCP 的 HTTP 包裝)與獨立的私有服務。

總結一下,我們已經有兩條防線:權杖上的 scopes 與網路邊界。再加上第三條——多租戶(multi‑tenant),一個 App 服務多個組織。

7. Multi‑tenant/組織脈絡

前面我們假定只有單一使用者。但許多 ChatGPT 應用都是 multi‑tenant:同一個 App 服務許多公司。GiftGenius 很容易變成企業用的 B2B 服務:各部門有各自的目錄、預算、訂單。

什麼是 tenant,從哪裡取得

Tenant 通常指:

  • 組織/公司(Acme Corp);
  • 工作空間(workspace);
  • 有時是專案或環境。

關鍵性質:一個 tenant 的資料不應對另一個 tenant 可見。

在 auth 流程中,tenant 通常放在:

  • 權杖的 claim(tenantorg_id);
  • 授權請求的獨立參數(但不如由 IdP 簽署的 claim 可靠)。

重點:只信任已驗證權杖中的 tenantId,而不是工具參數中的值。若模型生成了 {"tenantId": "acme"},但使用者權杖中是 tenantId: "globex",這應被視為入侵企圖。

請求脈絡中的 tenant

在我們的 RequestContext 中加入 tenantId(上文已加入),並且不能允許它被輸入資料覆寫。

基本檢查:

// 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 一個獨立資料庫;
  • 獨立的 schema;
  • 單一資料庫、各表包含 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 伺服器中,有接收來自 ChatGPT 的 HTTP/SSE 請求、檢查權杖並呼叫對應工具的程式碼。

把上述要點落地:

  1. 在 MCP 資源的 OAuth 設定中宣告 GiftGenius 的 scopes_supportedcatalog:readorders:write 等)。
  2. 在 Apps SDK 設定中描述 App,列出工具與其註解(read‑only、consequential、confirmation‑flows)。
  3. 在 MCP 伺服器中實作:
    • 權杖剖析與驗證;
    • 組合 RequestContext{ userId, tenantId, scopes }
    • helper:requireScopeenforceTenant 等;
    • 對資料庫的呼叫一律透過脈絡中的 tenantId

建立訂單的「隔離」路徑範例

我們追蹤一個 end‑to‑end 的情境。

  1. 使用者寫道:「為這個套組在 50$ 預算內下單」。
  2. 模型判斷需要呼叫 giftgenius.create_order,並帶上參數 { productId, budget, ... }
  3. ChatGPT 檢查 App 是否有 create_order 工具,以及為它宣告了哪些 scopes 與 securitySchemes。理解到需要 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 鎖定 tenant;
    • 僅在 tenantId="acme" 的範圍內建立訂單。
  7. 若此訂單需要即時付款,模型或後端隨後會啟動 charge_customer,此處:
    • 工具在計畫中標為 confirmationRequired
    • 小工具渲染 ConfirmCharge,請使用者明確確認扣款。

如此即可形成縱深防禦:過寬的 prompt、prompt 注入,甚至 UX 缺陷,都不會導致不受控的操作,因為在底層仍有嚴格的 scopes 檢查、tenant 約束與對關鍵行為的人為確認。

9. 設計權限與分段時的常見錯誤

錯誤一:像 app:full_access 這種「大而全」的 scope。
這種方式在 demo 時很方便,但在生產上很危險。丟了一把權杖——什麼都丟了。你無法只撤回或禁止某一操作而不影響其它。請按領域與操作類型拆分權限(read/write/critical)。

錯誤二:只在「入口」檢查權限,而不在工具內檢查。
有時會這樣做:「既然 ChatGPT 拿到了權杖,它就無所不能」。於是工具 createOrder 直接被呼叫,即使該權杖其實沒有 orders:write。正確作法是在每個工具中檢查 scopes(至少在所有會更動狀態的操作上以 middleware 集中檢查)。

錯誤三:未標示危險工具,也不要求確認。
若工具會扣款、刪除資料或改變存取權限,它不該在模型眼中與 listCatalog 一樣。缺乏明確的註記與 UX 確認,會提升模型基於「看起來合理」就去呼叫它的機率。至少請區分 read‑only 與 destructive 工具,並明確標示後者。

錯誤四:信任 tenantId 來自工具參數。
常見的反模式是:工具 getOrders({ tenantId })tenantId 由模型傳入。若直接拿來用,tenantA 的使用者只要填別的識別,就可能讀到 tenantB 的資料。Tenant 必須來自已驗證的權杖,並強制套用在所有對資料庫與外部服務的請求上;使用者提供的值要嘛忽略、要嘛驗證一致性。

錯誤五:MCP/資料庫直接對外網開放。
有時在簡單原型裡,MCP 伺服器與資料庫就直接以 HTTP/5432 對外開著。在生產環境絕不可行:存取必須經由單一受保護的 gateway/proxy,資料庫則待在私有網路。否則任何被發現的脆弱 endpoint 或洞穴百出的 webhook,都是直通資料的捷徑。

錯誤六:在 dev 與 prod 使用相同的 scopes/密鑰。
最容易在展示本機 dev 環境時,意外刪掉生產資料的方式之一。每個環境都應有自己的金鑰、scopes 與資料庫。即使有人取得 dev 權杖,也傷不到 prod。

錯誤七:不願意「拒絕模型」。
有時開發者會擔心:「如果我常回 insufficient_scopeforbidden,模型會不會變笨?」實務上這是正常且預期的:模型會學到哪些行為可做、哪些需要更多授權或確認。更糟的是它「成功」做了本不該做的事——例如重複扣款。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION