机构级多账户代理API的设计:从授权委托到无状态网关

在设计面向机构或B2B业务的API时,一个核心挑战超越了简单的用户认证:如何安全、高效地实现“代理操作”?即一个主体(Principal),如自动化交易程序或母公司管理员,需要代表多个其他账户(Target Accounts)执行操作。这种“多账户管理”需求在金融交易、云资源管理、广告投放平台等领域普遍存在。本文将从第一性原理出发,剖析代理API背后的授权委托模型,并最终落地到一个基于JWT和API网关的无状态、可水平扩展的架构。本文面向的是需要解决复杂权限和多租户问题的资深工程师与架构师。

现象与问题背景

设想一个典型的金融科技场景:一家量化对冲基金(我们称之为“机构A”)在我们的交易平台上开设了账户。机构A并非只有一个账户,而是根据不同的交易策略、风险隔离或客户资金来源,设立了数十个子账户(Sub-Accounts)。机构A的自研交易系统需要通过API与我们的平台交互,执行以下操作:

  • 为子账户 acc_strategy_01 下达一笔BTC/USDT的买单。
  • 查询子账户 acc_strategy_02 的当前持仓和资产。
  • 为所有子账户生成月度对账单。

一个初级的API设计可能要求机构A为每个子账户单独生成和管理一套API Key/Secret。这种方案在工程实践中会迅速崩溃:

  • 密钥管理噩梦:机构A需要维护一个庞大且不断变化的密钥库。密钥的轮换、分发、撤销都成为巨大的运维负担和安全隐患。
  • 身份归属与审计缺失:当子账户 acc_strategy_01 发生一笔异常交易时,审计日志只能记录是该账户的密钥发起了操作,但无法追溯到是机构A的哪一个交易程序、哪一位管理员触发的。操作主体的身份丢失了。
  • 权限控制僵化:我们无法实现更精细的权限控制。例如,我们无法授权机构A的“审计程序”只能读取所有子账户的账单,而“交易程序”只能在特定子账户上执行交易,不能提现。所有操作都绑定在账户级别的密钥上,权限是“全有或全无”。

问题的本质是,我们需要一个系统来管理“委托授权关系”(Delegated Authority)。即,子账户(资源所有者)将特定操作的权限委托给机构A的某个应用或用户(代理人)。API的设计必须能够清晰地承载和验证这种委托关系。

关键原理拆解

在进入具体实现之前,我们必须回归到计算机科学中关于安全与身份认证的基础模型。这能帮助我们建立正确的抽象,避免在错误的道路上堆砌复杂的工程实现。

(教授声音)

从理论层面看,该问题涉及三个核心概念:认证(Authentication)、授权(Authorization)和委托(Delegation)。

  • 认证 (Authentication):回答“你是谁?”。系统需要验证API调用者的身份。在我们的场景中,调用者是“机构A的交易程序”,而不是子账户本身。这是第一步,也是最基础的一步。
  • 授权 (Authorization):回答“你能做什么?”。在验证了调用者身份后,系统需要判断它是否有权限执行请求的操作。例如,这个交易程序是否有权限在子账户 acc_strategy_01 上执行“下单”操作。
  • 委托 (Delegation):这是授权的一个特例,指一个实体(Delegator,如子账户)将自己的部分权限授予另一个实体(Delegatee,如机构A的程序)。OAuth 2.0 框架就是描述这种委托授权的标准协议。虽然我们不一定需要完整实现OAuth 2.0的所有流程(例如三方应用的授权码流程),但其核心思想——特别是关于作用域(Scope)和访问令牌(Access Token)的定义——是构建我们系统的理论基石。

为了在分布式系统中高效、安全地传递这些信息,JSON Web Token (JWT) 成为了事实标准。一个JWT(特指JWS, JSON Web Signature)本质上是一个可自验证的、紧凑的声明集合。它由三部分组成:Header(头部)、Payload(载荷)和Signature(签名)。

  • Header:定义了令牌的类型和签名算法(如RS256)。
  • Payload:包含了“声明(Claims)”,这是问题的关键。标准声明如 iss (issuer), sub (subject), aud (audience), exp (expiration time) 提供了基础的身份和时效信息。我们可以通过自定义声明来承载授权和委托信息。
  • Signature:使用密钥(对称或非对称)对Header和Payload进行签名。这使得接收方可以验证令牌的完整性(未被篡改)和真实性(确实由授权方签发)。签名的存在,使得JWT可以在不安全的网络中传递,并让资源服务器(Resource Server)在不直接查询认证服务器(Authorization Server)的情况下,独立完成验证。这就是“无状态”验证的核心。

最后,我们需要一个访问控制模型来组织权限。基于角色的访问控制 (Role-Based Access Control, RBAC) 是最常见的模型。它通过“用户-角色-权限”的间接层来简化管理。在我们的场景中,可以定义“交易员”、“审计员”等角色,将它们分配给机构A的不同程序,角色再关联到具体的权限,如“在账户X上下单”或“读取账户Y的余额”。

系统架构总览

基于上述原理,我们可以勾勒出一个清晰、分层的系统架构。这个架构的核心是一个API网关(API Gateway),它作为所有外部请求的唯一入口,承担了认证和授权的职责,从而让后端的业务微服务保持纯粹和无知。

用文字描述这幅架构图:

  • 外部客户端 (Client):机构A的交易程序。
  • 认证服务 (Auth Service):一个独立的身份认证中心。客户端通过其凭证(如Client ID/Secret)向此服务请求令牌。认证服务验证凭证后,查询权限数据,生成一个包含委托授权信息的JWT,并返回给客户端。
  • API 网关 (API Gateway):所有业务请求的入口。它不处理具体业务逻辑,只做几件关键的事:
    1. 拦截所有请求,并从 Authorization 头中提取JWT。
    2. 验证JWT的签名和时效性。
    3. 解析JWT的载荷,获取调用者身份(sub)和其被授权操作的子账户列表及权限(自定义声明)。
    4. 根据请求的URL(如 /accounts/acc_strategy_01/orders)和HTTP方法(POST),判断本次操作的目标账户和所需权限。
    5. 执行授权检查:判断调用者是否有权对目标账户执行此操作。
    6. 若检查通过,则将请求转发到对应的下游微服务。在转发时,它会通过HTTP头将已验证的身份信息(如调用者ID、目标账户ID)注入请求,供下游服务使用。
    7. 若检查失败,直接返回 403 Forbidden
  • 下游微服务 (Downstream Microservices):如订单服务、账户服务等。它们完全信任从API网关转发过来的请求,不再进行重复的认证和授权检查。它们只需从请求头中读取身份信息,专注于执行业务逻辑。
  • 权限数据库/服务 (Permission Store):存储主体、账户、角色和权限之间的关系。认证服务在签发令牌时会查询它,在某些更动态的实现中,API网关也可能查询它。

这个架构的优势在于职责分离。业务服务开发者无需关心复杂的认证授权逻辑,而安全和权限控制逻辑则被集中在API网关和认证服务中,易于审计、维护和升级。

核心模块设计与实现

(极客工程师声音)

理论说完了,来看点硬核的。怎么用代码把这套东西攒出来。别想得太复杂,关键就三块:令牌怎么设计,网关怎么拦,权限怎么存。

1. JWT载荷设计:装载委托关系的“护照”

JWT就是那本授权护照,设计它的内容(Claims)至关重要。一个糟糕的设计会让整个系统变得笨拙。一个好的设计应该信息充分且不过度臃肿。

一个推荐的JWT Payload结构如下:

{
  "iss": "https://auth.my-exchange.com", // 签发者
  "sub": "inst_A_trade_bot_01",       // 主体, 即API调用者本身
  "aud": "https://api.my-exchange.com",   // 受众, 我们的API服务
  "exp": 1672531200,                    // 过期时间
  "jti": "a-unique-jwt-id",             // JWT唯一标识, 用于防重放
  "scopes": [ "trade", "query" ],       // 允许执行的操作类型
  "accounts": [                         // 明确授权可操作的子账户列表
    "acc_strategy_01",
    "acc_strategy_02"
  ]
}

这里的关键是自定义的 scopesaccounts 字段。scopes 定义了“能做什么”(动词),accounts 定义了“能对谁做”(宾语)。当机构A的交易程序带着这个令牌来请求操作 acc_strategy_01 时,网关只需检查:

  1. 请求的操作(如下单,可映射为 trade scope)是否在 scopes 列表里。
  2. 请求的账户(acc_strategy_01)是否在 accounts 列表里。

两者都满足,授权通过。这种设计将权限在令牌签发时就“固化”下来,网关的验证逻辑变得极快,因为无需在每次请求时都去查数据库。当然,这也带来了令牌一旦签发就难以撤销的问题,我们稍后在对抗层讨论。

2. 权限数据模型

令牌里的信息从哪来?显然是数据库。一个简化的RBAC模型可以这样设计:

-- API调用主体 (如机构的程序)
CREATE TABLE principals (
    id VARCHAR(50) PRIMARY KEY,
    institution_id VARCHAR(50),
    description TEXT
);

-- 目标账户 (如子账户)
CREATE TABLE accounts (
    id VARCHAR(50) PRIMARY KEY,
    owner_institution_id VARCHAR(50),
    ...
);

-- 角色
CREATE TABLE roles (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) UNIQUE
);

-- 权限 (对应JWT里的scopes)
CREATE TABLE permissions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    `scope` VARCHAR(50) UNIQUE
);

-- 角色-权限关联
CREATE TABLE role_permissions (
    role_id INT,
    permission_id INT,
    PRIMARY KEY (role_id, permission_id)
);

-- 主体-角色-账户 授权关系表 (核心)
CREATE TABLE principal_account_roles (
    principal_id VARCHAR(50),
    account_id VARCHAR(50),
    role_id INT,
    PRIMARY KEY (principal_id, account_id, role_id)
);

当认证服务要为 principal_id = 'inst_A_trade_bot_01' 签发令牌时,它会执行一个类似这样的查询,来构建JWT的Payload:

-- 查询该主体能操作的所有账户
SELECT DISTINCT account_id FROM principal_account_roles WHERE principal_id = '...';

-- 查询该主体拥有的所有权限(scopes)
SELECT DISTINCT p.scope
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
JOIN principal_account_roles par ON rp.role_id = par.role_id
WHERE par.principal_id = '...';

3. API网关的授权中间件

网关是这一切的执行者。无论是用Nginx+Lua,还是用Go、Java自研,其核心逻辑都是一个请求处理中间件。下面是一个Go语言的伪代码示例,展示了其核心逻辑:

// 
// AuthorizationMiddleware 是一个HTTP中间件
func AuthorizationMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 1. 提取Token
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
            http.Error(w, "Forbidden: Missing Authorization Header", http.StatusForbidden)
            return
        }
        tokenString := strings.TrimPrefix(authHeader, "Bearer ")

        // 2. 验证并解析JWT (publicKey需要预先加载)
        claims, err := verifyAndParseToken(tokenString, publicKey)
        if err != nil {
            http.Error(w, "Forbidden: Invalid Token", http.StatusForbidden)
            return
        }

        // 3. 提取目标账户和所需权限
        // 假设URL格式为 /v1/accounts/{accountId}/...
        targetAccount := extractAccountIDFromURL(r.URL.Path)
        requiredScope := mapRequestToScope(r.Method, r.URL.Path) // e.g., POST /orders -> "trade"

        if targetAccount == "" || requiredScope == "" {
            http.Error(w, "Bad Request: Cannot determine target resource", http.StatusBadRequest)
            return
        }

        // 4. 执行授权检查
        isAccountAllowed := false
        for _, allowedAccount := range claims.Accounts {
            if allowedAccount == targetAccount {
                isAccountAllowed = true
                break
            }
        }
        if !isAccountAllowed {
            log.Printf("AUTHZ FAILED: principal '%s' tried to access account '%s'", claims.Subject, targetAccount)
            http.Error(w, "Forbidden: Access to this account is not allowed", http.StatusForbidden)
            return
        }

        isScopeAllowed := false
        for _, allowedScope := range claims.Scopes {
            if allowedScope == requiredScope {
                isScopeAllowed = true
                break
            }
        }
        if !isScopeAllowed {
             log.Printf("AUTHZ FAILED: principal '%s' lacks scope '%s'", claims.Subject, requiredScope)
            http.Error(w, "Forbidden: Insufficient scope", http.StatusForbidden)
            return
        }

        // 5. 注入身份信息并放行
        // 这些头是给下游微服务用的,下游必须信任它们
        r.Header.Set("X-Principal-ID", claims.Subject)
        r.Header.Set("X-Target-Account-ID", targetAccount)

        next.ServeHTTP(w, r)
    })
}

这段代码干脆利落,把所有脏活累活都在网关层干完了。下游的订单服务只需要安心处理业务,这是典型的“关注点分离”。

对抗层(Trade-off 分析)

没有完美的架构,只有取舍。这个设计在解决核心问题的同时,也引入了新的挑战。

  • 无状态JWT vs. 有状态Session:JWT最大的优点是无状态和可扩展性。网关和微服务无需共享任何session状态,可以无限水平扩展。但代价是令牌撤销困难。一旦一个JWT被签发,在它过期之前,它就是有效的,即使我们已经在数据库中删除了用户的权限。常见的缓解手段包括:
    • 缩短令牌有效期:例如5-15分钟。这缩小了风险窗口,但要求客户端必须实现令牌刷新逻辑(Refresh Token),增加了客户端实现的复杂度。
    • 维护一个黑名单(Blacklist):在Redis中维护一个已撤销令牌的JTI列表。网关在验证令牌时,除了检查签名和有效期,还要查一下Redis。这牺牲了一部分无状态性,换取了更高的安全性。这是性能和安全之间的典型权衡。
  • 权限信息粒度:我们将可操作的账户列表直接放进了JWT。如果一个主体可以管理成千上万个账户,这个JWT的体积会变得非常大,增加网络开销。在这种极端情况下,可以考虑在JWT中只放入一个“权限版本号”或“会话ID”。网关在收到请求时,用这个ID去缓存或数据库中查询详细的权限列表。这又是一次典型的权衡:用一次额外的网络/缓存查询,换取更小的令牌体积。
  • 网关的性能与可用性:网关是整个系统的咽喉,它的性能和可用性至关重要。
    • 性能:JWT的验证(特别是RS256等非对称加密)是CPU密集型操作。必须对网关进行充分的性能压测。用于验证签名的公钥必须在内存中缓存,绝不能每次请求都去读取。
    • 可用性:网关必须是无状态的,并且至少部署N+1个节点,通过负载均衡器对外提供服务,确保单点故障不会影响整个系统。

架构演进与落地路径

一口吃不成胖子。对于一个现有系统,或者一个从零开始的新系统,可以分阶段引入这套架构。

  1. 阶段一:内部信任与显式参数

    在项目初期,如果API只对内部或极少数高度可信的客户开放,可以从最简单的模型开始:使用单个长期有效的API Key进行认证,并在API请求体或URL中明确传递需要操作的子账户ID。授权逻辑直接硬编码在业务服务中。这个阶段的重点是快速验证业务模式,但安全性和扩展性极差,必须明确其为临时方案。

  2. 阶段二:引入统一认证与JWT

    当客户增多,或需要对外开放API时,必须引入正式的认证服务。搭建Auth Service,废除简单的API Key,切换到标准的客户端凭证(Client Credentials)流程,颁发短期的JWT。此时的JWT可以只包含调用者身份(sub),授权逻辑仍然在各个微服务中。这一步解决了“认证”问题,但授权逻辑仍然分散。

  3. 阶段三:网关承载授权,实现代理模式

    这是质变的一步。引入API网关,将所有微服务置于其后。实现上文详述的授权中间件。修改Auth Service,在签发JWT时,根据权限数据库,将委托信息(如 accountsscopes)注入JWT。改造所有下游微服务,移除它们内部的授权逻辑,改为完全信任网关注入的X-Principal-ID等头信息。至此,我们便拥有了一个健壮、可扩展的代理API架构。

  4. 阶段四:迈向动态策略引擎(ABAC)

    对于权限规则极其复杂多变的场景(例如,需要支持“A机构的‘高级交易员’角色,只能在交易日的9:00-17:00,对风险等级为‘中’的欧洲区域子账户,执行单笔不超过100万美元的交易”这类规则),硬编码或基于RBAC的数据库模型会变得难以维护。此时,可以将授权决策逻辑从网关中剥离,交给一个专门的策略引擎(Policy Engine),如Open Policy Agent (OPA)。网关在收到请求后,将请求的上下文(主体、操作、资源、时间、IP地址等)打包发给OPA,OPA根据预先定义的策略(使用Rego等专用语言)返回一个简单的“允许”或“拒绝”的决定。这使得权限策略可以由安全或业务团队独立于代码进行管理和审计,是实现“策略即代码”(Policy as Code)的终极形态。

总之,设计多账户代理API是一个典型的系统工程问题,它要求我们不仅要理解业务需求,更要深刻洞察认证授权的底层原理,并在安全性、性能、可维护性之间做出明智的权衡。从一个简单的JWT开始,逐步演进到一个由API网关和策略引擎守护的、职责清晰的分布式架构,是通往大规模、高安全企业级服务的必由之路。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部