Files
form-center-service/docs/consumer-access-v3.md
T
chenxing d7473112b0
build / build-push (push) Successful in 1m26s
t1~t3落地+10-22精修: 消费授权v3(owner/grant/订阅/目录)+答卷投递v2(可信提交上下文/快照/幂等关联)+答卷对账v1(清单/派发/租约/结果)
含10-22精修六类修复: 续租renewRunning/结果INCOMPLETE_RESULT门/终态TASK_TERMINAL/liveGrant复用ACL/hosted幂等重放绑定/错误明细整条重序列化。
合规审计与证据: 工作区docs/tasks/REFINEMENT_AUDIT.md + evidence/refinement-20261022/(patch+指纹)。SQL未执行、测试未运行(仅-skipTests编译)。
2026-10-22 10:02:57 +08:00

118 lines
8.8 KiB
Markdown
Raw 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.
# 表单中心 · 消费应用授权与表单目录(v3,TASK_1)
对外契约的增补件,挂在《表单中心契约 v2》§9(对外读接口)之下。落地范围=**发现(目录)+ 授权(read/subscribe)+ 稳定引用(uuid+version)**,
不含部门选型、不含 Apply 输入换轨(TASK_2)、不含事件投递(TASK_3)。
依据:VERIFICATION V03/V04/V07;原骨架 T01 与 T05 的确切版本读取部分。
---
## §1 调用身份:只有一个来源
`/oapi/form/**` 的身份 = 平台 `AppAuthFilter` 验签通过后写在请求属性 `yh.app.auth` 上的 **appKey**。
- 三件套 `X-App-Key / X-Timestamp / X-Signature` 缺失 → 平台链 401(`enforce-patterns=[/oapi/*]`),进不到业务层;
- 属性拿到了但密钥不可用,或 `OpenApiAppClient.getAppByKey` 不能在UserBase确认同一appKey → `UNAUTHORIZED`;
trusted-apps只提供验签密钥,不充当注册事实;注册查询不可达也拒绝,不把降级放行写成可用性保证;
- 查询参数 `appCode` 与 `schId` **只是资源筛选与租户域**,不构成身份、不授予拥有权;
- `app_registry` 仍是快应用目录(四业务列、零角色),本批一行不加,也不因 slug 同名自动认领任何资源。
## §2 落型:资源拥有者与消费租户分开
| 表 | 键 | 语义 |
|---|---|---|
| `form_ownership` | `uk_ownership_form(form_uuid)` | 这张表单是哪个 appKey 的资产;`form_school_id` 存绑定时表单自己的 `sch_id`(0=平台级) |
| `form_consumer_grant` | `uk_grant(form_uuid, consumer_school_id, consumer_app_key)` | 允许"这个租户的这个应用"**读**和/或**订阅**这张表 |
- **默认拒绝**:`read_allowed` / `subscribe_allowed` 默认 0,没有隐式全集;
- **读与订阅分权**:owner 天然能读自己那张表的 schema/答卷,**订阅必须另开一行 grant**(能看 ≠ 能往外推);
- **租户维度不可省**:只按 appKey 授权会丢掉"读答卷仍以该校行集限定"这条。平台级表单(`form.sch_id=0`)对某校授予后,
那一校看到的答卷仍只有自己的行(行集由调用侧 `schId` 条件保证,授权不放宽它);
- **owner 由管理面写**:校管理员/超管在写入前核验 appKey 的 UserBase 注册身份(§1 同一张注册表,只判存在、不回显密钥),
平台级表单的 owner 绑定**仅超管**可设——否则校管理员可以把平台资源认领给自己应用;
- **统一拒绝口径**:跨校 / 缺归属 / 不存在 / 未授权 → 同一句 `表单不存在:<uuid>`,真实原因只进服务端 warn 日志;
`answer` 端点被拒时回答 `答卷不存在:<answerId>`(答"表单不存在"等于同时确认两张对象都存在);
- **撤销不删行**:`enabled=0` + `grant_version` 进位;重申同样是新纪元(版本再进位)。
DDL:`sql/form_consumer_access_v3.sql`(无物理外键,`form_uuid` 为逻辑引用;两表零存量、无需回填)。
## §3 目录端点与既有端点的授权
```
GET /oapi/form/forms?schId=&appCode=&keyword=&page=&pageSize=
→ data = { items:[{formUuid, code, name, appCode, version, audience[]}], total }
```
- 过滤链:`sch_id ∈ (schId, 0)` ∩ `is_deleted=0` ∩ `status=已发布` ∩ **调用方可读**(owner ∪ 显式 read grant),
**先过滤再分页**——`total` 是过滤后的条数,页数不随授权抖动;
- `page` 从 1 起算(缺省 1),`pageSize` 缺省 20、**上限 100**(超限直接拒,不静默收窄);
- `keyword` 对 `name`/`code` 做大小写不敏感的包含匹配,`appCode` 精确匹配(仅筛选);
- `version`=当前发布指针,目录只出已发布行所以必非空。消费方选中后**成对保存** `formUuid + version`;
- 不做本校对平台级的遮蔽:目录按 uuid 寻址,两张同 code 的表是两个都合法的可选项(遮蔽规则属于按 code 的 resolve 入口)。
既有三个读端点同一批过 `requireRead`(签名通过不再等于全开):
| 端点 | 判定时机 |
|---|---|
| `GET /oapi/form/schema?uuid=[&version=]` | 解析**之前**(手里有 uuid,被拒与不存在同句) |
| `GET /oapi/form/schema?code=[&appCode=]` | 解析之后、返回载荷之前(code 形态先要拿到 uuid) |
| `GET /oapi/form/answer?answerId=` | 定位答卷之后,拒绝时回答"答卷不存在" |
| `GET /oapi/form/answers?form_uuid=` | 游标解析之前 |
CAS 自用填报路径不受影响:服务端应用 ACL 不替代填报入口的会话身份与行级数据范围(契约 v2 §8 那套仍在会话身份上)。
## §4 消费方(svc-hall / flow-center)保存什么
- 两端各出一个**同源目录代理**(管理面调用,浏览器不持 appKey/secret):校域由 CAS 会话派生,不由前端传;
- 只保存**服务器验证过的** `formUuid` + `formVersion`:不信自由输入的 `appCode`,也不信前端送来的版本号;
- flow-center 发布期调 `schema?uuid=&version=`,**返回坐标与请求不一致即阻断发布**;已发布快照不改写,改关联走新版本;
- svc-hall 事项保存时远端校验失败即整次保存失败(不写半个绑定)。
## §5 与旧默认开放行为的差异(上线必读)
改造前:`/oapi/form/**` 只要**应用签名通过**,对声明域(`schId` 本校 ∪ 平台级)内的 schema/answer/answers **默认全开**,
没有表单级 ACL,也没有目录端点。改造后:**没有任何 owner 绑定或 grant 的表单,对外一律不可读**——
包括此前"签名就能读"的存量表单。
⇒ **部署前必须逐张补 `form_ownership`(中心自有表)或 `form_consumer_grant`(跨应用消费)**,否则现有消费链路会读不到表。
这是本批刻意选择的 fail-closed 取向(默认拒绝优先于兼容),不做"未配置=放行"的过渡开关。
**已知会在升级当场受影响的一条现成链路**:flow-center 的流程定义**发布期**快照(`FormCenterSchemaClient`,
C1 第一刀)现在就在校域内按 uuid 读 `/oapi/form/schema`。中心升级后若没有给 flow-center 的 appKey(联调态 100019)
补 owner 或 read grant,**发布会被拒**(表现为"快照拉取失败")。上线顺序因此固定为:
① 中心侧先建好绑定/授权(按消费应用逐个授 read,flow-center 与 svc-hall 各一条起)→ ② 中心发布 → ③ 消费方发布。
svc-hall 侧本次是新增链路(改造前不读中心),所以只有"选不到表"这一种表现,不影响存量事项。
其余对外可见差异:
1. 新增 `GET /oapi/form/forms`(纯增量,不改变任何既有响应形状);
2. `schema`/`answer`/`answers` 的**成功响应逐字节不变**,只是多了一层拒绝;
3. 拒绝文案统一到"表单不存在/答卷不存在",原先"未发布/停用"这类差别信息只在**通过读权判定之后**才会给出。
## §6 管理面端点(守卫=校管理员 ∪ 超管,与 app 注册同一把)
```
GET /api/form/admin/form-access/owner?formUuid=&schId= → {formUuid, formSchoolId, ownerAppKey} | null
PUT /api/form/admin/form-access/owner?formUuid=&schId= body {ownerAppKey}
GET /api/form/admin/form-access/grants?formUuid=&schId= → [{formUuid, consumerSchoolId, consumerAppKey,
readAllowed, subscribeAllowed, enabled, grantVersion}]
POST /api/form/admin/form-access/grants?schId= body {formUuid, consumerSchoolId, consumerAppKey,
readAllowed, subscribeAllowed}
POST /api/form/admin/form-access/grants/revoke?schId= body {formUuid, consumerSchoolId, consumerAppKey}
```
- 校级管理员的管理域=本校:`consumerSchoolId` 不等于会话域即拒;超管可代某校授权/换绑;
- `consumerSchoolId` 必须 ≥1(0 是资源域不是租户);`readAllowed`/`subscribeAllowed` 至少开一把;
- appKey 写入前必须能在平台密钥注册表查到(否则"绑一个不存在的身份"会把资源挂到空气上),**响应不回显任何密钥**。
## §7 后续任务的唯一入口(不许另起一套 ACL)
`FormConsumerAccessService` 是授权判定的唯一出口:
- `ConsumerPrincipal resolveSignedCaller(String appKey)`(`ConsumerPrincipalResolver`)
- `void requireRead(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
- `SubscriptionGrant requireSubscribe(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
- `List<SubscriptionGrant> listEnabledSubscriptions(String formUuid, Long answerSchoolId)`
TASK_2(Apply 输入换轨)与 TASK_3(事件投递)只调这四个接口;`SubscriptionGrant.grantVersion` 必须随投递下发,
消费方据此识别"这份授权已作废"。**撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。**