build / build-push (push) Successful in 1m26s
含10-22精修六类修复: 续租renewRunning/结果INCOMPLETE_RESULT门/终态TASK_TERMINAL/liveGrant复用ACL/hosted幂等重放绑定/错误明细整条重序列化。 合规审计与证据: 工作区docs/tasks/REFINEMENT_AUDIT.md + evidence/refinement-20261022/(patch+指纹)。SQL未执行、测试未运行(仅-skipTests编译)。
118 lines
8.8 KiB
Markdown
118 lines
8.8 KiB
Markdown
# 表单中心 · 消费应用授权与表单目录(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` 必须随投递下发,
|
||
消费方据此识别"这份授权已作废"。**撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。**
|