Agent 工程体系 · 第 76/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 认证与授权:Actor、租户、对象权限、Scope 和二次校验

Agent 的安全问题,通常不是“有没有登录”这么简单。

一个 Agent 可能同时代表用户、组织、自动化任务和自身的运行时身份;它可能访问多个租户的数据、调用多个 MCP Server、读取记忆、生成代码、执行写操作,还可能在用户离开页面后继续运行。如果系统只在入口处校验一次 JWT,或者仅凭 scope=write 判断权限,最终往往会出现以下问题:

  • 用户 A 的 Agent 读到了租户 B 的文档;
  • 普通成员通过 Agent 修改了自己无权修改的订单;
  • 低风险的“查询工具”被组合成高风险的数据导出能力;
  • 用户授权给 Agent 的令牌被转发到错误的下游服务;
  • 用户在审批后权限被撤销,但后台任务仍然继续执行;
  • Agent 使用管理员身份创建了一个普通用户无法直接创建的资源;
  • 所有权校验通过后,资源在实际写入前已经被转移,形成竞态漏洞。

因此,Agent 的授权不能被简化为一个布尔值:

authorized = true

更可靠的模型是:

一次操作是否允许
= 身份真实性
× Actor 语义
× 租户边界
× Scope
× 对象权限
× 当前状态
× 风险等级
× 二次校验

其中任意一个条件不成立,操作都应被拒绝或转入人工确认。


一、先区分认证、授权和责任归属

1. 认证回答“你是谁”

认证(Authentication,简称 AuthN)验证一个请求携带的凭据是否能证明某个身份。例如:

  • 用户通过密码、Passkey 或企业单点登录完成认证;
  • Agent Runtime 使用工作负载身份获取令牌;
  • MCP Client 使用 OAuth Authorization Code + PKCE 获取访问令牌;
  • 后台任务使用短期的服务身份令牌调用内部 API。

认证的结果通常是一个经过验证的主体:

{
  "subject": "user_123",
  "issuer": "https://id.example.com",
  "auth_time": "2026-09-01T08:30:00Z",
  "amr": ["webauthn"]
}

这里的 subject 只能说明“这个令牌代表谁”,并不能直接说明“这个主体可以对哪个对象做什么”。

2. 授权回答“你能做什么”

授权(Authorization,简称 AuthZ)判断一个已经认证的主体是否有权执行特定动作:

主体 user_123 是否可以对对象 document_456 执行 update?

授权至少包含四个输入:

(actor, action, resource, context)

例如:

(user_123, update, document_456, 当前租户、对象状态、认证强度)

如果只检查 actor,而不检查 resource,就是典型的水平越权风险;如果只检查 action,而不检查当前状态,可能把已归档订单重新修改;如果不检查 context,则无法表达“只能在工作时间操作”或“超过金额必须二次审批”。

3. 责任归属回答“谁发起、谁执行、谁负责”

Agent 系统还需要记录责任链。一个请求可能同时包含:

  • Human User:实际使用系统的人;
  • Agent:根据用户意图进行规划和决策的软件主体;
  • Agent Runtime:承载 Agent 执行循环的运行时;
  • Tool Client:调用工具协议的客户端;
  • Tool Server / MCP Server:暴露工具能力的服务;
  • Service Account:用于机器间调用的服务身份;
  • Resource Owner:拥有数据或资源的用户、组织或租户。

“谁登录了”和“谁执行了”不一定是同一个主体。例如用户 Alice 授权 Agent 修改合同,但实际发起 HTTP 请求的是 agent-runtime-prod。审计记录不能只写:

{
  "subject": "agent-runtime-prod",
  "action": "update_contract"
}

至少应包含:

{
  "initiator": {
    "type": "human",
    "id": "user_alice"
  },
  "delegate": {
    "type": "agent",
    "id": "agent_contract_ops"
  },
  "executor": {
    "type": "workload",
    "id": "agent-runtime-prod"
  },
  "tenant": "tenant_acme",
  "action": "update",
  "resource": "contract_928",
  "decision": "allow"
}

这三个身份分别用于授权和审计:

身份 主要问题
initiator 谁提出或批准了意图
delegate 哪个 Agent 被授予了代理能力
executor 哪个运行时、服务或工具实际执行了动作

如果把这三者压缩成一个 sub,系统很难判断一个动作究竟是用户直接完成、Agent 代办,还是后台任务自行扩展了权限。


二、Actor:Agent 不是用户,用户也不等于 Actor

1. Actor 的定义

Actor 是授权决策中的“行动主体”。在传统 Web 应用中,Actor 常常就是当前登录用户;在 Agent 系统中,Actor 可能是:

用户
Agent
工作负载
服务账号
后台任务
外部系统
用户与 Agent 的组合

一个完整的 Actor 表示可以写成:

Actor = {
  type,
  id,
  tenant,
  authority,
  delegation,
  authentication_strength,
  execution_context
}

例如:

{
  "type": "agent",
  "id": "agent_finance_assistant",
  "tenant_id": "tenant_acme",
  "authority": {
    "subject_id": "user_alice",
    "delegated_by": "user_alice"
  },
  "authentication_strength": "user_session_with_mfa",
  "execution_context": {
    "run_id": "run_20260901_001",
    "interactive": true
  }
}

这里的 Agent 不是凭提示词“声称”自己代表 Alice,而是由可信的身份系统建立代理关系。

2. “代表用户”不等于“拥有用户全部权限”

设用户 Alice 的权限集合为:

P(user_alice) = {
  read:invoice,
  update:invoice,
  approve:invoice,
  export:invoice
}

Agent 被授权的权限集合应是一个受限子集:

P(agent) ⊆ P(user_alice)

例如:

P(agent) = {
  read:invoice,
  update:invoice
}

即使 Alice 自己拥有 approve:invoice,Agent 也不能因此自动获得审批权限。

更严格地说,代理权限应满足:

EffectivePermission(agent, user)
=
UserPermission(user)
∩
DelegationPolicy(agent, user)
∩
RuntimePolicy(agent)

其中:

  • UserPermission:用户本身拥有的权限;
  • DelegationPolicy:用户允许 Agent 代行的权限;
  • RuntimePolicy:平台针对该 Agent 类型、工具或环境配置的限制。

因此,如果 Alice 允许 Agent 读写发票,但组织规定 Agent 永远不能审批付款,则:

approve ∉ RuntimePolicy(agent)

最终仍然必须拒绝。

3. 代理链不能无限透明传递

考虑如下调用链:

用户 Alice
  → Agent A
    → MCP Client
      → MCP Server
        → ERP API

错误实现可能把 Alice 的原始访问令牌一路转发:

Authorization: Bearer alice_token

这会导致下游服务误以为令牌是为自己签发的,或者误以为上游已经完成了所有授权检查。MCP 的授权规范明确要求,MCP Server 必须验证收到的访问令牌确实是发给自己的,并且不得把客户端令牌直接透传到上游 API;调用上游时应使用面向上游资源、由对应授权服务器签发的另一个令牌。(modelcontextprotocol.io)

正确模型是令牌交换或重新获取:

alice_token
  --验证 audience=mcp.example.com-->
mcp_session
  --按最小权限申请-->
erp_token(audience=erp.example.com, scope=invoice.read)

这不仅是令牌格式问题,也是责任边界问题:

MCP Server 对客户端负责
ERP API 对 MCP Server 的服务身份负责

若下游需要知道原始用户,可以通过受控的委托声明传递:

{
  "sub": "mcp-service",
  "act": {
    "sub": "agent_finance_assistant"
  },
  "may_act": {
    "sub": "user_alice"
  },
  "aud": "erp-api",
  "scope": "invoice.read"
}

但下游不能因为看到 may_act.user_alice 就跳过自身授权检查。


三、租户:身份隔离之外,还要隔离授权上下文

1. 租户不是用户组字段

租户(Tenant)是多租户系统中资源、策略、配额和密钥的隔离边界。一个用户可能属于多个租户:

user_alice ∈ {tenant_acme, tenant_beta}

因此,不能只把 tenant_id 存在用户资料里,然后在请求中默认使用第一个租户。授权请求必须明确当前租户:

{
  "subject": "user_alice",
  "active_tenant": "tenant_acme"
}

“用户属于租户”与“当前请求作用于哪个租户”是两个不同事实。

2. 租户边界必须进入每一次授权判断

可将资源表示为:

Resource = {
  id,
  tenant_id,
  owner_id,
  type,
  state,
  attributes
}

最基本的授权条件是:

resource.tenant_id = actor.active_tenant

但这只是必要条件,不是充分条件。

例如 Alice 属于 tenant_acme,对 document_1 的请求如下:

{
  "actor": {
    "subject": "user_alice",
    "tenant": "tenant_acme"
  },
  "resource": {
    "id": "document_1",
    "tenant": "tenant_beta"
  },
  "action": "read"
}

即使 Alice 在 tenant_beta 也有同名角色,只要本次请求的活动租户是 tenant_acme,该对象仍然不能被访问,除非系统显式支持跨租户委托,并且该委托经过独立授权。

3. 数据库查询必须同时表达租户条件

以下查询是不安全的:

SELECT *
FROM documents
WHERE id = :document_id;

因为只要攻击者猜中或获得另一个租户的 document_id,查询就可能返回越权数据。

安全查询至少应是:

SELECT *
FROM documents
WHERE id = :document_id
  AND tenant_id = :active_tenant_id;

更新和删除也必须带租户条件:

UPDATE documents
SET content = :content,
    version = version + 1
WHERE id = :document_id
  AND tenant_id = :active_tenant_id
  AND version = :expected_version;

这里的 version 用于防止对象在授权检查和写入之间发生变化。单独在应用层先查询、再更新,无法避免下面的竞态:

T1: 查询 document_1,发现属于 tenant_acme,允许更新
T2: 资源所有权或租户归属发生变化
T3: T1 使用旧判断结果继续写入

把租户、对象版本和必要的状态条件放入同一条条件更新,可以让数据库参与最后一道原子性约束。

4. Agent 的租户上下文不能由模型生成

以下做法不可接受:

tenant_id = tool_arguments["tenant_id"]

因为工具参数可能来自模型,而模型输出不是可信安全边界。租户应从经过验证的请求上下文中获得:

tenant_id = request.auth.active_tenant_id

如果业务确实支持跨租户操作,则应要求调用方拥有明确的跨租户权限,并对目标租户执行重新授权:

if target_tenant != auth.active_tenant_id:
    require_permission(auth, "tenant.cross_read", target_tenant)
    require_step_up(auth, reason="cross_tenant_access")

四、对象权限:RBAC 只能回答一半问题

1. Scope、角色和对象权限处于不同层次

三者不能互相替代:

  • 角色(Role):主体属于什么权限集合,例如 editorfinance_admin
  • Scope:令牌被允许访问哪类能力,例如 invoice.read
  • 对象权限(Object Permission):对某一个具体对象是否允许某个动作,例如 Alice 能否修改发票 inv_1001

一个角色可能产生一组权限:

Role(editor)
→ {document.read, document.update}

Scope 则限制当前令牌:

Token.scope = {document.read}

对象策略进一步限制资源:

document_1001.owner_id = user_alice
document_1001.state = draft

最终决策可以写成:

Allow =
  Authenticated(actor)
  ∧ TenantMatch(actor, resource)
  ∧ ScopeAllows(token, action)
  ∧ RoleAllows(actor, action)
  ∧ ObjectPolicyAllows(actor, action, resource)
  ∧ StateAllows(resource, action)

缺一不可。

2. 一个完整算例

假设:

Actor: Agent A,代表 Alice
Tenant: acme
Action: update
Resource: invoice_1001

系统状态:

{
  "user": {
    "id": "alice",
    "tenant": "acme",
    "roles": ["finance_editor"]
  },
  "agent": {
    "id": "agent_finance",
    "delegated_scopes": ["invoice.read", "invoice.update"]
  },
  "token": {
    "aud": "invoice-api",
    "scope": "invoice.read invoice.update",
    "auth_time": "2026-09-01T08:30:00Z"
  },
  "invoice": {
    "id": "invoice_1001",
    "tenant": "acme",
    "owner": "alice",
    "state": "draft",
    "amount": 8000
  }
}

逐步判断:

第一步:认证

token.signature_valid = true
token.exp > now = true
token.aud = invoice-api = true

身份认证通过。

第二步:租户匹配

actor.tenant = acme
invoice.tenant = acme

通过。

第三步:Scope

invoice.update ∈ token.scope

通过。

第四步:用户角色

finance_editor → invoice.update

通过。

第五步:代理范围

invoice.update ∈ agent.delegated_scopes

通过。

第六步:对象策略

invoice.owner = alice
actor.delegation.subject = alice

通过。

第七步:状态策略

invoice.state = draft
update 允许作用于 draft

通过。

所以本次请求允许。

但如果发票状态是:

state = "approved"

即使前六步都通过,最终仍应拒绝:

approved 发票不可直接修改

如果金额为 800000,还可能进入二次校验,而不是直接允许。

3. 反例:只检查 Scope

错误代码:

def update_invoice(request):
    if "invoice.update" not in request.token.scopes:
        raise Forbidden()

    invoice = db.get_invoice(request.json["invoice_id"])
    invoice.content = request.json["content"]
    db.save(invoice)

攻击者只需获得一个具有 invoice.update 的令牌,就可能修改:

  • 其他用户的发票;
  • 其他部门的发票;
  • 其他租户的发票;
  • 已审批或已结算的发票。

Scope 证明的是:

这个令牌可以请求 invoice.update 这类能力

它没有证明:

这个令牌可以修改任意 invoice

五、Scope:令牌能力的上限,而不是完整授权结论

1. Scope 的语义

Scope 是 OAuth 访问令牌携带的权限范围,通常是空格分隔的字符串:

scope = "invoice.read invoice.update"

资源服务器应将 Scope 解释为令牌可以尝试访问的能力集合,而不是最终对象权限。

可以把 Scope 看成一个上界:

RequestedAction ⊆ TokenScope

如果请求动作不在 Scope 中,必定拒绝;如果动作在 Scope 中,还必须继续执行对象和上下文判断。

2. Scope 应表达能力,不应表达所有业务条件

适合放入 Scope 的内容:

document.read
document.update
invoice.read
invoice.pay
calendar.write

不适合把所有动态条件编码成 Scope:

invoice.update:tenant_acme:invoice_1001:until_10:30

这样会造成令牌数量、缓存、撤销和策略管理的复杂度爆炸。对象归属、金额、状态、审批人等条件应由授权服务根据当前资源状态判断。

3. Scope 设计的两个陷阱

陷阱一:Scope 过宽

scope = "admin"

Agent 获得一个过宽 Scope 后,模型可能通过工具组合完成原本不希望它完成的动作。OWASP 将 LLM 应用中的过度代理能力列为 Excessive Agency 风险,并强调应限制功能、权限和自主行动范围。(genai.owasp.org)

陷阱二:Scope 名称看似细粒度,实际实现仍然全开放

例如系统定义了:

customer.read
customer.update
customer.delete

但代码只是:

if "customer.update" in scopes:
    db.execute("UPDATE customers SET ... WHERE id = ?", [id])

如果没有租户、对象所有权和状态条件,这仍然是全局对象写权限。

4. Scope 的收缩原则

令牌申请时,客户端请求的 Scope 应不超过它实际需要的范围:

scope_requested ⊆ scope_allowed_by_client_policy

Agent 调用不同工具时,也不应长期持有整个会话的最大权限。可以采用按工具、按任务或按动作申请的短期令牌:

执行搜索:
  scope = document.read

执行草稿修改:
  scope = document.read document.update

执行发布:
  scope = document.publish
  + step-up authentication

这里的关键是把“发现信息”和“产生不可逆副作用”分离。


六、二次校验:高风险动作不能只依赖初始登录

1. 二次校验是什么

二次校验(Step-up Authentication / Re-authentication)是在已有会话或令牌基础上,针对高风险操作再次验证用户或授权事实。

它不一定等于“再次输入密码”,可以是:

  • Passkey / WebAuthn;
  • MFA;
  • 短信或硬件令牌;
  • 企业审批;
  • 对高风险参数进行用户确认;
  • 重新确认目标对象、金额和收款方;
  • 对后台任务重新签发一次性执行凭证。

关键在于:初始认证证明“用户登录过”,二次校验证明“用户现在愿意批准这个具体高风险动作”。

2. 风险分级

可以定义风险函数:

Risk(action, resource, context)

例如:

Risk =
  action_weight
  + amount_weight
  + reversibility_weight
  + external_effect_weight
  + tenant_scope_weight

并设置阈值:

Risk < 30       → 直接允许
30 ≤ Risk < 70  → 用户确认
Risk ≥ 70       → 强认证 + 审批

这不是通用标准公式,而是一个便于工程实现的决策模型。真正的安全要求来自组织策略、业务风险和合规边界。

适合要求二次校验的动作通常包括:

  • 删除数据;
  • 发布或公开内容;
  • 修改权限和成员;
  • 支付、退款、转账;
  • 导出大量敏感数据;
  • 跨租户访问;
  • 修改密钥、Webhook 或 OAuth Client;
  • 执行高权限代码或生产环境命令。

3. 用户确认必须绑定具体动作

错误的确认方式:

“Agent 要执行一个操作,是否继续?”

这种确认没有告诉用户:

  • 操作对象是谁;
  • 将发生什么变化;
  • 是否不可逆;
  • 影响金额是多少;
  • 哪些外部系统会被调用。

更安全的确认凭证应绑定操作摘要:

{
  "approval_id": "approval_789",
  "actor": "user_alice",
  "tenant": "acme",
  "action": "invoice.pay",
  "resource": "invoice_1001",
  "amount": 8000,
  "destination": "supplier_77",
  "request_hash": "sha256:...",
  "expires_at": "2026-09-01T09:00:00Z",
  "used": false
}

执行时重新计算请求摘要:

hash(current_request) == approval.request_hash

只有一致时才能使用审批。

如果用户批准的是:

支付 invoice_1001,金额 8,000 元

Agent 随后把金额改成 80,000 元,审批必须失效,而不能被视为对“支付发票”这个抽象动作的永久授权。

4. 二次校验不是模型确认

以下内容不能作为安全确认:

模型输出:“用户已经确认,可以执行。”

模型生成的自然语言不是可信凭据。可信确认必须来自:

  • 前端用户交互;
  • 认证服务;
  • 审批系统;
  • 一次性签名;
  • 服务器端产生且不可伪造的审批记录。

七、完整授权决策模型

可以将一次 Agent 工具调用形式化为:

Request = (a, t, s, r, op, c)

其中:

  • a:Actor;
  • t:活动租户;
  • s:访问令牌及 Scope;
  • r:资源对象;
  • op:动作;
  • c:上下文,例如时间、认证强度、任务状态和审批信息。

定义授权函数:

Permit(Request) =
  AuthN(a, s)
  ∧ Tenant(a, t, r)
  ∧ Scope(s, op)
  ∧ Delegation(a, op)
  ∧ ObjectPolicy(a, op, r)
  ∧ StatePolicy(op, r.state)
  ∧ RiskPolicy(op, r, c)
  ∧ StepUpSatisfied(op, r, c)

1. 推导过程

假设请求是:

Agent A 代表 Alice
在租户 acme
对 invoice_1001 执行 pay

初始事实:

AuthN(A, token) = true
Tenant(A, acme, invoice_1001) = true
Scope(token, invoice.pay) = true

但:

invoice_1001.amount = 80,000
Risk(invoice.pay, invoice_1001) = high
StepUpSatisfied(pay, invoice_1001) = false

根据合取逻辑:

true ∧ true ∧ true ∧ false = false

所以必须拒绝或返回“需要二次校验”,而不能因为前三项为真就执行支付。

2. 拒绝原因应可诊断但不泄露敏感信息

对调用方可以返回:

{
  "error": "step_up_required",
  "required_action": "reauthenticate",
  "approval_scope": {
    "action": "invoice.pay",
    "resource": "invoice_1001"
  }
}

不应返回:

“用户 Alice 没有 finance_admin 角色,但 Bob 有。”

因为错误信息可能泄露组织成员、角色和对象存在性。服务端日志可以记录更详细的策略命中情况,但日志本身必须脱敏并防止令牌泄露。


八、Agent 调用链中的认证与授权时序

sequenceDiagram
    participant U as 用户
    participant UI as Agent UI
    participant AS as 授权服务器
    participant AR as Agent Runtime
    participant PDP as 策略决策点
    participant MCP as MCP Server
    participant ERP as ERP API

    U->>UI: 登录并选择租户 acme
    UI->>AS: Authorization Code + PKCE
    AS-->>UI: Access Token(scope=invoice.read invoice.update)
    UI->>AR: 用户任务 + 令牌上下文
    AR->>PDP: 请求 invoice_1001 的 update 授权
    PDP-->>AR: Allow
    AR->>MCP: Bearer token + 工具参数
    MCP->>MCP: 验证签名、issuer、audience、exp、scope
    MCP->>PDP: 重新执行对象权限和状态检查
    PDP-->>MCP: Allow
    MCP->>ERP: 使用 ERP 专用令牌
    ERP-->>MCP: 更新结果
    MCP-->>AR: 工具结果
    AR-->>UI: 返回结果和审计引用

关键路径有两次授权判断:

  1. Agent Runtime 的预检查:避免模型调用明显不允许的工具;
  2. MCP Server 的强制检查:防止绕过 Agent Runtime 直接调用工具。

第一次检查是优化和用户体验的一部分,第二次检查才是资源服务必须承担的安全边界。不能因为 Runtime 已经检查过,就让 MCP Server 信任来自请求体的 authorized=true


九、MCP 中的 OAuth、客户端身份、Scope 和令牌

1. MCP 的角色映射

在基于 HTTP 的 MCP 授权模型中,可以将角色映射为:

MCP / OAuth 角色 典型实体
Resource Owner 用户或组织
OAuth Client Agent Host、MCP Client
Authorization Server 企业 IdP 或 MCP 授权服务
Resource Server 受保护的 MCP Server
Upstream Resource Server ERP、CRM、云平台 API

MCP 授权规范基于 OAuth 机制,并规定 HTTP 传输场景下的发现、令牌使用和资源绑定。MCP Server 作为资源服务器,MCP Client 作为客户端,授权服务器负责向客户端签发用于访问 MCP Server 的令牌。(modelcontextprotocol.io)

2. Authorization Code + PKCE 适合用户代理

用户参与的典型流程是:

MCP Client
  → 发现 MCP Server 的授权服务器
  → 生成 code_verifier 和 code_challenge
  → 打开授权页面
  → 用户登录并批准
  → 返回 authorization code
  → 使用 code_verifier 换取 token

PKCE 的作用是防止授权码被截获后被其他客户端兑换。MCP 授权规范要求客户端使用 PKCE,并要求严格校验重定向 URI 和状态参数。(modelcontextprotocol.io)

伪代码:

verifier = random_urlsafe_string()
challenge = base64url(sha256(verifier))
state = random_urlsafe_string()

authorize_url = build_url(
    authorization_endpoint,
    response_type="code",
    client_id=client_id,
    redirect_uri=registered_redirect_uri,
    code_challenge=challenge,
    code_challenge_method="S256",
    state=state,
    resource="https://mcp.example.com"
)

# 用户完成授权后返回 code 和 state
if returned_state != state:
    raise SecurityError("state mismatch")

token = post(token_endpoint, {
    "grant_type": "authorization_code",
    "code": returned_code,
    "redirect_uri": registered_redirect_uri,
    "client_id": client_id,
    "code_verifier": verifier,
    "resource": "https://mcp.example.com"
})

这里的 resource 不是普通业务参数,而是 OAuth Resource Indicators 机制中的资源标识。MCP 客户端应在授权请求和令牌请求中指定目标 MCP Server,并使用 MCP Server 的规范 URI;MCP Server 需要验证令牌确实面向自己签发。(modelcontextprotocol.io)

3. Client Credentials 适合纯机器身份

没有用户参与时,可以使用客户端凭据模式或对应 MCP 扩展:

Agent Runtime
  --client_id + client_secret / JWT assertion-->
Authorization Server
  --access_token(scope=inventory.read)-->
Agent Runtime

该模式表达的是:

这个应用或工作负载有权访问资源

它不天然表达:

某个具体用户已经批准这次操作

因此,使用 Client Credentials 调用支付、删除或权限变更工具时,仍需要额外的业务审批、服务端策略或人类确认。MCP 官方提供了面向机器间场景的 OAuth Client Credentials 扩展,但它是对用户交互式授权流程的补充,不是把应用身份自动变成用户身份。(modelcontextprotocol.io)

4. 401 和 403 必须区分

MCP Server 或普通资源服务器应区分认证失败和授权失败:

401 Unauthorized
= 没有有效身份,或者令牌无效、过期、签发者错误、audience 不匹配

403 Forbidden
= 身份有效,但 Scope、角色、对象权限或策略不足

例如:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

适用于:

令牌不存在
令牌签名错误
令牌已过期
令牌不是发给当前 MCP Server

而:

HTTP/1.1 403 Forbidden
Content-Type: application/json

{"error":"insufficient_scope"}

适用于:

令牌有效,但没有 invoice.pay

MCP 授权规范同时要求令牌放在 Authorization: Bearer ... 请求头中,不得放在 URL 查询字符串中;无效或过期令牌应返回 401,Scope 不足应返回 403。(modelcontextprotocol.io)


十、MCP 授权服务器发现与版本边界

截至 2026 年 9 月,MCP 的授权规范仍然是版本敏感的,不能把不同版本页面中的要求混写。

2025 年 6 月的 MCP HTTP 授权规范要求:

  • MCP Server 提供 OAuth 2.0 Protected Resource Metadata;
  • MCP Client 根据资源元数据发现授权服务器;
  • 客户端获取 OAuth Authorization Server Metadata;
  • 支持 PKCE;
  • 对令牌执行 audience 校验;
  • 禁止令牌透传。(modelcontextprotocol.io)

官方 MCP 项目在 2026 年 7 月 28 日发布了 2026-07-28 版本,并引入了无会话协议核心、基于请求头的工具路由和授权强化。其中授权相关变化包括更严格的 issuer 校验、客户端身份元数据文档方向,以及逐步从 Dynamic Client Registration 转向 Client Metadata Documents。(blog.modelcontextprotocol.io)

因此,工程实现应明确写出支持的 MCP 版本:

supported_protocol_versions:
  - 2025-06-18
  - 2026-07-28

并为每个版本测试:

  • 授权服务器发现路径;
  • WWW-Authenticate 处理;
  • resource 参数;
  • issuer 校验;
  • PKCE 支持;
  • 客户端注册方式;
  • 令牌 audience;
  • 401 / 403 行为。

不能因为某个客户端支持新版本,就假定所有 MCP Server 都支持相同的发现机制和客户端注册机制。


十一、对象授权的实现方式

1. 集中式策略决策

可以把授权拆成两个组件:

PEP(Policy Enforcement Point)
= 执行策略,拒绝或放行请求

PDP(Policy Decision Point)
= 根据 Actor、动作、资源和上下文计算决策

调用结构:

decision = policy_engine.check(
    actor=auth.actor,
    tenant_id=auth.active_tenant_id,
    action="invoice.update",
    resource=invoice,
    context={
        "token_scopes": auth.scopes,
        "auth_time": auth.auth_time,
        "run_id": run_id
    }
)

if decision.effect != "allow":
    raise Forbidden(decision.reason)

PDP 返回结果可以包含:

{
  "effect": "deny",
  "reason": "resource_state_forbids_update",
  "obligations": [],
  "policy_version": "invoice-policy-42"
}

策略版本写入审计日志后,可以回答:

这次请求是在什么策略版本下被允许或拒绝的?

2. 数据库行级安全

如果数据库支持 Row-Level Security,可以让租户边界靠近数据层:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY invoice_tenant_isolation
ON invoices
USING (tenant_id = current_setting('app.tenant_id'));

事务开始时设置上下文:

BEGIN;

SELECT set_config(
  'app.tenant_id',
  'tenant_acme',
  true
);

SELECT *
FROM invoices
WHERE id = 'invoice_1001';

COMMIT;

但数据库行级安全不能替代动作授权。它可以防止读取其他租户的行,却不能独立判断:

当前 Actor 是否能执行 pay?
发票状态是否允许 update?
是否需要二次审批?

因此应组合使用:

应用层:动作、Scope、角色、对象策略、风险
数据库层:租户行隔离、最终写入条件

3. 能力令牌

对后台 Agent,可以使用不可转移或范围受限的能力令牌:

{
  "capability_id": "cap_123",
  "subject": "agent_finance",
  "tenant": "acme",
  "action": "invoice.update",
  "resource": "invoice_1001",
  "constraints": {
    "max_amount": 10000,
    "expires_at": "2026-09-01T09:00:00Z",
    "max_uses": 1
  }
}

能力令牌适合表达一次明确授权,但必须防止:

  • 被复制到其他租户;
  • 被用于其他对象;
  • 被重复使用;
  • 过期后仍有效;
  • 从低风险对象升级到高风险对象。

一次性能力令牌的消费应当是原子操作:

UPDATE capabilities
SET used_at = CURRENT_TIMESTAMP
WHERE id = :capability_id
  AND used_at IS NULL
  AND expires_at > CURRENT_TIMESTAMP
  AND tenant_id = :tenant_id;

检查受影响行数:

1 → 成功消费
0 → 不存在、已使用、过期或租户不匹配

如果先 SELECTUPDATE,并发请求可能同时观察到 used_at IS NULL,从而造成重复执行。


十二、后台任务、记忆和缓存中的授权失效

1. 后台任务不能永久继承前台权限

Agent 经常会把任务放入队列:

用户请求
  → Agent 创建任务
  → 队列
  → Worker 延迟执行

错误实现是把原始 Access Token 整个存入任务:

{
  "job_id": "job_1",
  "access_token": "eyJ..."
}

问题包括:

  • 令牌可能在队列、日志和死信队列中暴露;
  • 用户权限撤销后,任务仍使用旧令牌;
  • 任务执行时租户、对象状态和审批状态可能已经变化;
  • 任务可能跨越很长时间,初始上下文已不再有效。

更合理的任务载荷是授权引用:

{
  "job_id": "job_1",
  "run_id": "run_1",
  "initiator_id": "user_alice",
  "agent_id": "agent_finance",
  "tenant_id": "tenant_acme",
  "action": "invoice.update",
  "resource_id": "invoice_1001",
  "approval_id": "approval_789"
}

Worker 执行时重新获取或交换短期令牌,并重新执行授权:

读取任务
  → 检查任务未取消
  → 查询当前租户和对象
  → 检查用户或代理授权是否仍有效
  → 检查对象版本和状态
  → 检查审批未过期且摘要匹配
  → 获取短期执行令牌
  → 原子写入

2. 记忆必须携带租户和可见性

Agent 记忆不能只有文本:

{
  "content": "客户希望下周续费"
}

至少应包含:

{
  "memory_id": "mem_1",
  "tenant_id": "tenant_acme",
  "owner_type": "user",
  "owner_id": "alice",
  "visibility": "private",
  "source_run_id": "run_1",
  "created_at": "2026-09-01T08:30:00Z"
}

向量检索也必须带租户过滤:

similarity(query, embedding)
WHERE tenant_id = active_tenant
  AND visibility IN (allowed_visibility)

不能先全局召回,再在模型上下文中“提醒模型不要使用其他租户的数据”。一旦越权内容进入上下文,后续输出、摘要和记忆写入都可能扩大泄露范围。OWASP 将敏感信息披露、向量和嵌入弱点列为生成式 AI 应用的重要风险类别。(genai.owasp.org)

3. 授权缓存必须绑定完整键

不安全的缓存键:

authz:{user_id}:{action}

它缺少:

  • 租户;
  • 对象;
  • 对象版本;
  • Agent;
  • Scope;
  • 策略版本;
  • 二次校验状态。

更完整的缓存键可以是:

authz:v42:
  tenant_acme:
  actor_agent_finance:
  subject_alice:
  invoice.update:
  invoice_1001:
  object_version_7:
  scope_hash_abc:
  stepup_false

权限撤销、租户切换、对象转移、策略发布后,都必须使相关缓存失效。对于高风险写操作,宁可不缓存最终决策,也不要用过期的 allow 结果执行不可逆动作。


十三、常见失败表现与诊断方法

1. 出现“偶发跨租户数据”

重点检查:

请求租户是否来自可信上下文?
数据库查询是否始终带 tenant_id?
向量检索是否带租户过滤?
缓存键是否包含 tenant_id?
后台任务是否保存并校验 tenant_id?
工具参数中的 tenant_id 是否覆盖了认证上下文?

审计日志应能关联:

request_id
run_id
actor_id
initiator_id
tenant_id
resource_id
action
policy_version
decision
denial_reason

如果日志只有:

user=alice, tool=search, success=true

就无法判断 Alice 搜索的是哪个租户、哪个对象范围,也无法重建 Agent 的行为链。

2. 出现“Scope 正确但仍越权”

通常说明 Scope 被误当成对象权限。检查工具实现是否存在:

if "document.read" in scopes:
    return get_document(document_id)

应改为:

document = get_document(
    id=document_id,
    tenant_id=auth.active_tenant_id
)

require_object_permission(
    actor=auth.actor,
    action="document.read",
    resource=document
)

3. 出现“用户撤销后任务仍执行”

检查:

  • 队列是否存储了长期令牌;
  • Worker 是否重新查询授权;
  • 任务是否可取消;
  • 令牌撤销是否能传播到资源服务;
  • 审批是否绑定对象版本;
  • 任务重试是否会绕过原始授权。

4. 出现“用户说没有批准过”

检查审批记录是否绑定:

actor
tenant
action
resource
arguments_hash
auth_time
approval_time
expiration
used_at

如果审批只记录:

user_alice approved invoice.pay

而没有金额、对象和参数摘要,就无法证明用户批准的是哪一次具体操作。

5. 出现“MCP Server 接受了别的服务令牌”

检查:

iss 是否正确?
aud 是否包含当前 MCP Server?
签名算法和密钥是否来自正确 issuer?
令牌是否通过 Authorization Header 传递?
是否存在把客户端令牌直接转发到上游的代码?

MCP 规范特别强调资源受众绑定和禁止令牌透传,因为错误处理会形成 confused deputy:下游服务把本来发给其他资源的令牌当作合法凭据接受。(modelcontextprotocol.io)


十四、规范保证、实现选择与经验建议

需要把三类内容分开。

规范或协议要求

例如:

  • OAuth 令牌应经过资源服务器验证;
  • MCP HTTP 请求使用 Bearer Authorization Header;
  • MCP Server 应验证令牌 audience;
  • 无效令牌返回 401,权限不足返回 403;
  • MCP 客户端使用 PKCE;
  • 令牌不得放在 URL 查询参数中;
  • MCP Server 不应透传收到的客户端令牌。(modelcontextprotocol.io)

这些是协议层的要求或规范语义。

常见实现

例如:

  • JWT 作为访问令牌;
  • OPA、Cedar 或自研策略引擎作为 PDP;
  • PostgreSQL RLS 实现租户行隔离;
  • Redis 缓存授权决策;
  • Worker 执行时重新交换短期令牌;
  • 使用审批表保存二次确认状态。

这些是实现方式,不代表所有系统都必须采用。

经验建议

例如:

  • 高风险动作不缓存最终允许结果;
  • 代理权限取用户权限、委托范围和运行时策略的交集;
  • 工具参数中的租户字段不能覆盖认证上下文;
  • 审批必须绑定动作、对象和参数摘要;
  • 审计记录同时保留 initiator、delegate 和 executor;
  • 让 MCP Server 自己成为不可绕过的授权边界。

这些建议来自 Agent 的执行特性和常见故障模式,具体阈值仍需根据业务风险配置。

NIST AI RMF 是用于管理人工智能风险的自愿性框架,目标是将可信性考虑纳入 AI 系统的设计、开发、使用和评估;它不是一套直接定义 Actor、Scope 或租户策略的授权协议。因而,工程团队可以用它组织风险识别、测量、管理和治理,但仍需自行把这些要求落实为身份、策略、日志和执行控制。(nist.gov)


十五、一个可落地的最小授权接口

下面的接口展示授权决策所需的最小输入:

from dataclasses import dataclass
from typing import FrozenSet


@dataclass(frozen=True)
class Actor:
    kind: str
    id: str
    tenant_id: str
    delegated_subject_id: str | None


@dataclass(frozen=True)
class Resource:
    kind: str
    id: str
    tenant_id: str
    owner_id: str | None
    state: str
    version: int


@dataclass(frozen=True)
class AuthorizationRequest:
    actor: Actor
    scopes: FrozenSet[str]
    action: str
    resource: Resource
    step_up_id: str | None


def authorize(req: AuthorizationRequest) -> tuple[bool, str]:
    if req.actor.tenant_id != req.resource.tenant_id:
        return False, "tenant_mismatch"

    if req.action not in req.scopes:
        return False, "insufficient_scope"

    if req.actor.kind == "agent":
        if req.actor.delegated_subject_id is None:
            return False, "missing_delegation"

        if req.resource.owner_id != req.actor.delegated_subject_id:
            return False, "object_permission_denied"

    if req.action == "invoice.update" and req.resource.state != "draft":
        return False, "invalid_resource_state"

    if req.action in {"invoice.pay", "invoice.delete"}:
        if req.step_up_id is None:
            return False, "step_up_required"

    return True, "allow"

这个示例没有实现完整的生产级身份验证、策略版本、数据库事务和撤销机制,但它展示了几个不可省略的事实:

  1. 租户来自 Actor 和 Resource 的比较;
  2. Scope 只检查动作是否在令牌能力内;
  3. Agent 还需要代理主体;
  4. 对象权限与资源状态独立判断;
  5. 高风险动作需要二次校验;
  6. 最终结论不是由模型决定,而是由服务端授权函数决定。

生产环境中,authorize() 的返回值还应与数据库写入绑定,避免“授权成功但写入了另一个版本的对象”:

UPDATE invoices
SET status = 'paid',
    version = version + 1
WHERE id = :invoice_id
  AND tenant_id = :tenant_id
  AND version = :expected_version
  AND status = 'approved';

若影响行数为零,必须把操作视为失败,并重新读取对象状态,而不能继续假设授权仍然有效。


Agent 安全授权的核心,不是给模型更多或更少的工具,而是建立一条不可模糊的责任链:

谁发起
→ 哪个 Agent 代理
→ 哪个运行时执行
→ 在哪个租户中
→ 对哪个对象
→ 执行什么动作
→ 持有什么 Scope
→ 满足什么对象和状态条件
→ 是否经过二次校验
→ 最终由哪个服务强制执行

Actor 解决“行动主体”问题,租户解决“隔离边界”问题,对象权限解决“具体资源”问题,Scope 解决“令牌能力上限”问题,二次校验解决“高风险动作的即时意愿”问题。只有这些条件共同参与决策,Agent 才不会因为“代表用户”而获得用户全部权限,也不会因为“拿到了 write Scope”就能修改系统中的任意对象。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。