不要把用户 OAuth Token 直接塞给 Agent:用 Spring Boot 做一个 Delegated Authority Gateway

很多 Agent 项目最开始的授权逻辑非常直接:

用户连接 Google / CRM / GitHub
↓
系统拿到 OAuth Token
↓
Agent 需要 Tool 时直接使用

PoC 阶段很方便。

生产以后,问题会越来越多:

Agent 代表哪个用户?
这次调用为了哪个任务?
为什么有权访问这个资源?
权限能用多久?
一个 Token 被几个 Agent 共享?
高风险动作有没有二次批准?

所以我更倾向在 Agent 与外部 Tool 之间加一个:

Delegated Authority Gateway

Agent 不直接持有长期 Token。

它只声明:

我需要什么 Capability
为了什么 Purpose
要访问什么 Resource

网关根据用户连接、组织角色、Agent Policy 和当前 Run,签发一个短期 Execution Grant。

目标架构

User Connection
      ↓
Credential Vault
      ↓
Agent Run
      ↓
Delegated Authority Gateway
├─ Identity
├─ Purpose
├─ Capability Policy
├─ Resource Scope
├─ Approval
└─ Audit
      ↓
Ephemeral Credential
      ↓
Tool / MCP / SaaS API

长期 Credential 永远留在 Vault。

Agent 只拿短期能力。

Maven 依赖

        org.springframework.boot
        spring-boot-starter-web



        org.springframework.boot
        spring-boot-starter-security



        org.springframework.boot
        spring-boot-starter-oauth2-resource-server



        org.springframework.boot
        spring-boot-starter-data-jpa



        org.springframework.boot
        spring-boot-starter-actuator



        io.micrometer
        micrometer-registry-prometheus

这里没有强绑定某个 OAuth Provider。

因为授权网关应该服务:

Google
Microsoft
GitHub
Salesforce
内部系统
MCP

第一层:用户连接不是执行权限

用户完成 OAuth 后,保存的是 Connection。

public record UserConnection(
        String connectionId,
        String tenantId,
        String subjectId,
        String provider,
        Set grantedScopes,
        String credentialRef,
        ConnectionStatus status,
        Instant connectedAt,
        Instant expiresAt) {
}

credentialRef 指向 Secret Vault。

数据库里不要存明文 Refresh Token。

状态:

public enum ConnectionStatus {
    ACTIVE,
    REAUTH_REQUIRED,
    REVOKED,
    EXPIRED
}

重点是:

Connection 只表示
“用户曾经连接了某个系统”

不是:

“所有 Agent 永久可以使用这些权限”

第二层:Agent 声明 Capability,而不是声明 Token

请求:

public record AuthorityRequest(
        String runId,
        String stepId,
        String agentId,
        String tenantId,
        String subjectId,
        String purpose,
        String capabilityId,
        List resourceIds,
        JsonNode actionSummary) {
}

例如:

{
  "runId": "run-1842",
  "stepId": "step-7",
  "agentId": "sales-renewal-agent",
  "tenantId": "team-a",
  "subjectId": "user-82",
  "purpose": "renewal-risk-review",
  "capabilityId": "crm.customer.read",
  "resourceIds": [
    "customer/12345"
  ]
}

Agent 不需要知道:

OAuth Scope 是什么
Refresh Token 在哪
Access Token 怎么续

这些属于 Gateway。

第三层:把 Purpose 变成正式字段

public record PurposeContext(
        String purposeId,
        String runId,
        String taskType,
        Instant createdAt,
        Instant expiresAt,
        Set allowedCapabilities) {
}

例如:

renewal-risk-review

允许:

crm.customer.read
contract.read
support.ticket.read

不允许:

crm.customer.delete
email.bulk_send

这比只看用户角色更细。

Policy Decision

public record AuthorityDecision(
        boolean allowed,
        boolean approvalRequired,
        Set providerScopes,
        Set allowedResources,
        Duration credentialTtl,
        int maximumCalls,
        List reasons,
        String policyVersion) {
}

不要只返回:

true / false

生产里常见第三种状态:

可以做
但需要人批准

一个简单 Policy Engine

@Service
public class DelegatedAuthorityPolicy {

    public AuthorityDecision evaluate(
            AuthorityRequest request,
            UserConnection connection,
            CapabilityPolicy capability) {

        if (!connection.subjectId()
                .equals(request.subjectId())) {
            return AuthorityDecisionFactory.deny(
                    "SUBJECT_MISMATCH");
        }

        if (!capability.allowedPurposes()
                .contains(request.purpose())) {
            return AuthorityDecisionFactory.deny(
                    "PURPOSE_NOT_ALLOWED");
        }

        if (!capability.matchesResources(
                request.resourceIds())) {
            return AuthorityDecisionFactory.deny(
                    "RESOURCE_OUT_OF_SCOPE");
        }

        boolean approval =
                capability.riskLevel()
                        .compareTo(RiskLevel.HIGH) >= 0;

        return AuthorityDecisionFactory.allow(
                capability.providerScopes(),
                request.resourceIds(),
                Duration.ofMinutes(
                        approval ? 5 : 15),
                capability.maxCallsPerRun(),
                approval,
                capability.policyVersion());
    }
}

实际系统还会接:

  • RBAC;
  • ABAC;
  • Tenant Policy;
  • Data Classification;
  • Work Hours;
  • Risk;
  • Device;
  • Location。

但核心流程一样。

Capability Policy

public record CapabilityPolicy(
        String capabilityId,
        RiskLevel riskLevel,
        Set allowedPurposes,
        Set providerScopes,
        List allowedResourcePatterns,
        int maxCallsPerRun,
        String policyVersion) {

    public boolean matchesResources(
            List resources) {

        return resources.stream()
                .allMatch(this::matches);
    }

    private boolean matches(
            String resource) {

        return allowedResourcePatterns
                .stream()
                .anyMatch(resource::startsWith);
    }
}

生产中不要直接用 startsWith 做安全匹配,这里只是示意。

真正应该使用标准化 Resource Identifier。

Execution Grant

Policy 通过以后,不立即给 Token。

先生成:

public record ExecutionGrant(
        String grantId,
        String runId,
        String stepId,
        String agentId,
        String subjectId,
        String tenantId,
        String purpose,
        String capabilityId,
        Set resources,
        int remainingCalls,
        Instant expiresAt,
        GrantStatus status) {
}

状态:

public enum GrantStatus {
    CREATED,
    WAITING_APPROVAL,
    ACTIVE,
    CONSUMED,
    REVOKED,
    EXPIRED
}

高风险 Grant 先停在审批

例如:

email.send
crm.update
cloud.deploy
payment.refund

创建:

WAITING_APPROVAL

审批内容不要只有:

是否允许?

应该显示:

Agent:
sales-agent

代表:
张三

动作:
向 customer/12345 发送续约邮件

目的:
renewal-campaign-2026Q3

有效时间:
5 分钟

最大调用:
1 次

用户知道自己在批准什么。

Approval 绑定 Input Hash

否则审批之后 Agent 改参数。

public record ApprovalRecord(
        String approvalId,
        String grantId,
        String actionHash,
        String approvedBy,
        Instant approvedAt,
        Instant expiresAt) {
}

执行时:

当前 Action Hash
必须等于
批准时的 Hash

如果收件人或金额变化:

重新批准

Credential Broker

真正需要调用 Provider 时:

public interface CredentialBroker {

    EphemeralCredential issue(
            UserConnection connection,
            ExecutionGrant grant,
            AuthorityDecision decision);
}

返回:

public record EphemeralCredential(
        String accessToken,
        Instant expiresAt,
        Set scopes,
        String tokenFingerprint) {
}

accessToken 只活几分钟。

最好只存在内存,不写数据库和日志。

为什么不要把 Refresh Token 给 Agent

Refresh Token 能持续获取新 Access Token。

如果 Agent 拿到它:

Run 结束

也不代表权限结束。

更安全的结构:

Agent
永远不知道 Refresh Token

Gateway
按每次 Execution Grant
换短期 Access Token

这样 Run 结束以后,能力自然衰减。

Tool Proxy

Agent 不直接调用第三方 API。

@RestController
@RequestMapping("/api/tool")
public class DelegatedToolController {

    private final DelegatedToolService service;

    @PostMapping("/{capabilityId}")
    public ToolResponse invoke(
            @PathVariable String capabilityId,
            @RequestBody ToolInvocationRequest request,
            Authentication authentication) {

        return service.invoke(
                capabilityId,
                request,
                authentication);
    }
}

Tool Request:

public record ToolInvocationRequest(
        String runId,
        String stepId,
        String grantId,
        JsonNode arguments) {
}

调用前再次校验 Grant

@Transactional
public ToolResponse invoke(
        String capabilityId,
        ToolInvocationRequest request,
        Authentication auth) {

    ExecutionGrant grant =
            grantRepository.lockById(
                    request.grantId());

    grantValidator.requireActive(
            grant,
            capabilityId,
            request.runId(),
            request.stepId());

    approvalValidator.requireValidIfNeeded(
            grant,
            request.arguments());

    if (grant.remainingCalls()  resourceIds,
        Set allowedFields) {
}

如果 SaaS Provider 自己不支持字段级权限,Gateway 至少可以在响应侧做数据裁剪。

Response Filtering 也属于授权

很多团队只限制请求。

其实读取 Tool 返回后,也应该检查:

是否返回了超出 Resource Scope 的数据

例如 CRM API 一次返回整个客户对象。

Agent 只需要:

name
stage
recent_activity

Gateway 可以过滤:

{
  "name": "...",
  "stage": "...",
  "recent_activity": [...]
}

而不把:

billing
private_notes
personal_phone

全部交给模型。

Delegated Authority 还需要 Data Classification

例如:

PUBLIC
INTERNAL
CONFIDENTIAL
RESTRICTED

Capability Policy 可以规定:

普通 Agent:
最高 CONFIDENTIAL

代码沙箱:
最高 INTERNAL

高安全 Agent:
需要额外批准才能读取 RESTRICTED

权限不只是“能不能调 Tool”。

还包括:

能看什么数据

Grant Revocation

必须支持:

public void revokeByRun(
        String runId) {
    grantRepository.revokeActiveByRun(runId);
}

以及:

revokeByUser
revokeByAgent
revokeByConnection
revokeByCapability
revokeByTenant

例如员工离职:

User Disabled
↓
Revoke Connections
↓
Revoke Active Grants

角色变化也要触发 Re-evaluation

HR 系统发:

RoleChanged

Authority Service 检查:

现有 Connection 是否仍允许
现有 Grant 是否仍允许

不要等 Token 自然过期几个月。

Metrics

delegated_grant_total{
  decision,
  capability
}

delegated_grant_active

delegated_approval_total{
  result
}

delegated_credential_issued_total{
  provider
}

delegated_invocation_denied_total{
  reason
}

delegated_grant_revoked_total{
  reason
}

高基数的 user/run 不放普通 Metric,放 Trace。

Trace

agent.run
└─authority.request
  ├─connection.resolve
  ├─policy.evaluate
  ├─approval.wait
  ├─credential.issue
  └─tool.invoke

自动化测试最少覆盖这些

用户 A 不能使用用户 B Connection
Agent 超出 Purpose 被拒绝
Resource 超范围被拒绝
Grant 过期后调用失败
Grant 调用次数耗尽
审批后参数变化重新审批
Connection 撤销后 Grant 失效
并发调用不会超用 Grant
日志不出现 Access/Refresh Token

一个很关键的故障策略:Authority Service 挂了怎么办

低风险只读任务:

可以考虑使用短期已签名 Grant

高风险写任务:

Fail Closed

不要因为授权服务不可用,就临时绕过授权。

为什么 Signed Grant 有价值

Gateway 可以签一个短期 JWT:

{
  "run": "run-1842",
  "agent": "sales-agent",
  "sub": "user-82",
  "cap": "crm.customer.read",
  "resources": ["customer/12345"],
  "purpose": "renewal-review",
  "exp": 1780000000
}

Tool Gateway 本地验证签名,不需要每个调用都查中央数据库。

这可以降低授权服务成为全局瓶颈的风险。

高风险 Capability 仍然可以强制在线校验。

最后的生产边界

我会坚持四点:

1. Agent 不拥有长期用户 Token
2. Connection 不等于 Execution Grant
3. Grant 必须绑定 Purpose 和 Resource
4. 高风险动作绑定 Approval 和 Input Hash

这四条一旦建立,OAuth、MCP、SaaS Tool 和内部 API 都可以挂在同一个授权模型下面。


很多 Agent 身份问题看起来很复杂,其实核心不是重新发明 OAuth。

真正要补的是传统 OAuth 很少直接表达的三件事:

这次任务为什么需要权限
这次只允许访问哪些资源
这个 Agent 可以把授权用多久

所以 Delegated Authority Gateway 的价值,不是再包一层 API。

而是把:

用户长期连接

转换成:

Agent 本次任务真正需要的最小、短期、可审计权限

当 Agent 开始能发邮件、改 CRM、操作云资源、访问企业文件以后,这一层会比 Prompt 里的“请谨慎操作”重要得多。


更多企业级 AI 应用、Agent、RAG 与模型工程化内容,我会继续整理在 智元界

https://www.zyentor.com/