Files

152 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`。