9.4 KiB
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= 与头两收
(降级轮询共用同一条口)。
帧序与编号:
hello(带connectionId)必须先于回放——客户端拿到 id 之前收到的事件无从对账 (它还不知道该拿哪个 id 去/subscribe);- 每条推送帧带
id: <seq>,seq是 hub 内单调递增的全局序号; 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 的三条守则:
- 形状预检先于任何变更:
add里有一个不可订阅的主题就整单 422、零副作用 (边订边查会把合法的那条真订上、还白跑一次跨服务往返,客户端以为全失败而服务端留了半截订阅); add/remove必须是数组,不是就 422(类型断言不是校验:字符串走到.find是 TypeError→500, 请求写错却报成服务端故障,而 500 在网关这层还会被当成可重试);- 只能操作自己名下的连接:
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 5987filename*=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。