Files

9.4 KiB
Raw Permalink Blame History

BPOS-Service-Gateway 产品需求(能力面 stub)

本文是能力面 stub,字段级契约以协议册与 src/contracts 为准。

上游《BPOS仓库结构》约定"各服务的产品需求文档随各自服务仓 docs/ 维护",本文是本仓那一份。


1. 一句话定位

API 网关:会话鉴权 · 按域路由(task/data/agent)· SSE 推送中心 · 上传转对象存储。 前端唯一入口,8800,无自有库。

(照录《BPOS仓库结构》docs/proto-v13 分支 §1 表格中本仓那一行;该行标注 【提案 · 待架构负责人确认】——网关落仓在 §3"待定项"里尚未定稿。)


2. 能力面清单

每一项都对得上仓内已存在的路由或模块。

2.1 会话鉴权

  • requireAuth / requireRealmRole / requireService,JWKS 远端验签(与 Task/Data 同一份实现,同一性文件)。
  • 鉴权失败分类只此一份:token 坏 → 401;JWKS 拉不动 → 503(不是 401)。 SSE 是长连,客户端拿到 401 会清 token 去重登录,而重登录打的还是同一个 Keycloak ——把"鉴权服务暂不可达"报成 401 会制造一场无解的重登录风暴。
  • GET /health 公开(不过 requireAuth),汇总 nats 依赖状态;无自有库故无 db 探活。

2.2 按域路由(代理)

/api/task/* → TASK_SERVICE_URL、/api/data/* → DATA_SERVICE_URL、 /api/agent/* → AGENT_SERVICE_URL。全方法透传(r.all)。

挂载顺序 load-bearing:/api/stream 与 /api/upload 必须先于 /api 代理挂载, 否则被代理的 catch-all 吞掉。

三条安全约束(每条都是踩过的):

约束 做法 不这么做的后果
主机不可位移 在 new URL(target) 上赋 pathname,绝不 new URL(路径, target) 剥前缀后若余下 //evil.example.com/x 这种协议相对路径,URL 解析只保留 scheme 而把 host 整个换掉——调用方的 Authorization 与请求体发去攻击者主机,响应还原样回传(SSRF + 令牌外泄)
内部头剥离 转发前删掉 x-user-id、x-user-roles、x-internal-token、host、content-length 外部调用方自带 x-user-id 就能冒充任意身份——下游若信这类头即越权
响应头剥离 回传前删掉 content-encoding、content-length、transfer-encoding undici 已按 content-encoding 解过压、body 是明文,原样回传就是自相矛盾的响应(客户端二次解压失败或按错长度截断)

Authorization 原样带上(下游自己再验一遍——纵深防御)。 下游连不上 → 统一形状的 502 并点名是哪个下游。 traceparent:调用方没带就在这里补发一条(网关是外部请求的第一跳),已有的绝不覆盖 (那会把上游已开好的链从中间截断)。

  • 落点:src/modules/proxy/。

2.3 SSE 推送中心

GET /api/stream(建流)+ POST /api/stream/subscribe(改订阅集)。

token 取法:EventSource 带不了 Authorization 头,故 ?access_token= 与头两收 (降级轮询共用同一条口)。

帧序与编号:

  1. hello(带 connectionId)必须先于回放——客户端拿到 id 之前收到的事件无从对账 (它还不知道该拿哪个 id 去 /subscribe);
  2. 每条推送帧带 id: <seq>,seq 是 hub 内单调递增的全局序号;
  3. event: 是主题名,data: 是原始载荷。

Last-Event-ID 回放:客户端重连带 Last-Event-ID 头(只认纯数字), hub 在 512 条的环形窗口里补发 seq > lastEventId 且本连接可见的事件。

诚实的局限:EventSource 重连不携带订阅状态,新连接的 topics 是空集, 所以实际只补得到 touch.system 与 touch.staff.<sub>。任务域的补发要求客户端收到 hello 后先重新 /subscribe——而那时这一跳已经过去。真正的任务域断点续传要把订阅集做成 服务端持久态(按 staffSub 记住上次订阅),尚未做。

心跳:每 25s 发一帧 event: ping,data: {}。 流的寿命挂在一个可被两端 resolve 的 promise 上:客户端 abort、服务端 shutdown 都能收流 (否则心跳定时器与整个闭包会一直漏着不回收)。

可订阅主题与授权(默认拒;可订阅集 = 有授权规则的集合):

主题形状 授权判据
touch.task.<taskId> 问任务服务 GET /task/:id/access?staff=<sub>(负责人/审批人/admin)
stream.task.<taskId> 同上;额外按需建 NATS 订阅,最后一个订阅者退订即撤
touch.workflow.<workflowId> 问任务服务 GET /workflow/:id/access?staff=<sub>——判据是该实例定义里的节点归属(参与人 ownerSub / 审批人 approverSub),与单张单的归属不是一回事,故是另一口而非复用
touch.system 人人可见(无需订阅)
touch.staff.<sub> 只对本人可见(无需订阅)

admin 旁路两条 access 询问(既是规则,也免掉白跑的跨服务往返)。 两次询问都走服务账号 token(不是转发用户 token),3s 超时,非 200 一律当无权 ——推送面失败关闭:宁可少订一条,不可把别人的任务事件扇给不该看的人;但非 200 会打日志, 否则"人人被拒"无从诊断。

/subscribe 的三条守则:

  1. 形状预检先于任何变更:add 里有一个不可订阅的主题就整单 422、零副作用 (边订边查会把合法的那条真订上、还白跑一次跨服务往返,客户端以为全失败而服务端留了半截订阅);
  2. add/remove 必须是数组,不是就 422(类型断言不是校验:字符串走到 .find 是 TypeError→500, 请求写错却报成服务端故障,而 500 在网关这层还会被当成可重试);
  3. 只能操作自己名下的连接:hub.subscribe 拿的是连接的 staffSub 授权, 不校验归属的话任何登录用户只要知道别人的 connectionId 就能改人家的订阅集, 还能拿 results 当权限探测器。admin 例外。

运行时状态(连接不存在/被拒)逐条落 results 回 200,客户端据此决定重连还是申请权限。

  • 落点:src/modules/sse/(hub.ts、routes.ts、task-access.ts、workflow-access.ts)。

2.4 上传转对象存储

  • POST /api/upload(user/admin/service):8MB 上限, bodyLimit 先看 content-length、没有就边读边计数、一超限立刻断流 ——413 必须在收完 body 之前定,1GB 的 POST 不会先整个躺进内存再挨拒。 返回 { ref }(201)。x-filename 可选;记 ownerSub(只落盘,读侧鉴权尚未做)。
  • GET /api/upload/:ref:回原始字节,一律 Content-Disposition: attachment(不内联) + X-Content-Type-Options: nosniff——content-type 是上传方自报的,原样回显+内联渲染 就是网关源上的存储型 XSS。文件名走 RFC 5987 filename*=UTF-8''… (filename="…" 只认 ASCII,中文名塞 percent-escape 会原样显示成 %E4%B8%AD)。
  • 存储是 FS 适配器(OBJECT_STORE_DIR,缺省 ./.objectstore); objectstore.ts / fs-store.ts 与 Task 仓 channel/ 下那两份是同一性文件(逐字节相同)。
  • 落点:src/modules/upload/。

3. 明确不做(边界)

  • 不持久化:无自有库、无 db:migrate、无 outbox、无 relay、无 consumer、无 durable。
  • 不做业务判断:可见性一律问任务服务,网关不自己算归属。
  • 不建流、不发布业务事件:只 core NATS 订阅 touch.> 与按需的 stream.task.*。
  • 上传读侧的归属鉴权:GET /api/upload/:ref 现在只做验签+realm 角色校验 (user/admin/service),不校验对象归属——拿到 ref 的任何登录用户都读得到。 ownerSub 已随上传落盘,归属规则待后续。
  • SSE 任务域断点续传:见 §2.3 的局限说明,待订阅集服务端持久化。
  • 不做本地设施兜底:见《开发环境册》§2.1。

4. 依据链

依据 位置 用来定什么
《系统架构设计.html》v2 §07「两端与实时推送」 BPOS-Document-Share/系统架构设计.html 网关的会话鉴权、按域路由、SSE 扇出
《系统架构设计.html》v2 §06「消息总线:四类消息,五项保障」 同上 总线的能力要求(BUS-*)——本仓只涉及 BUS-08 尽力而为推送
《系统架构设计.html》v2 §10「持久层与文档体系」 同上 文档与契约的家:谁维护什么、放在哪
《BPOS服务消息协议》SVC 分节 BPOS-Document-Share/BPOS服务消息协议.md /task/:id/access、/workflow/:id/access 等服务间同步接口
《BPOS仓库结构》§1 / §3 BPOS-Document-Share/BPOS仓库结构.md(docs/proto-v13 分支) 本仓定位一行(提案待确认)与"网关落仓"待定项
本仓 src/contracts/envelope.ts 本仓 信封结构(同一性文件,真源在 BPOS-Document-Share)

仓内既有记录:docs/keycloak-setup-log.md。 验收演练的一手记录在姊妹仓:BPOS-Service-Task/tools/n2-acceptance.md、n3-acceptance.md、 BPOS-Service-Data/tools/n1-acceptance.md。