含10-22精修六类修复: 续租renewRunning/结果INCOMPLETE_RESULT门/终态TASK_TERMINAL/liveGrant复用ACL/hosted幂等重放绑定/错误明细整条重序列化。 合规审计与证据: 工作区docs/tasks/REFINEMENT_AUDIT.md + evidence/refinement-20261022/(patch+指纹)。SQL未执行、测试未运行(仅-skipTests编译)。
8.8 KiB
表单中心 · 消费应用授权与表单目录(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 侧本次是新增链路(改造前不读中心),所以只有"选不到表"这一种表现,不影响存量事项。
其余对外可见差异:
- 新增
GET /oapi/form/forms(纯增量,不改变任何既有响应形状); schema/answer/answers的成功响应逐字节不变,只是多了一层拒绝;- 拒绝文案统一到"表单不存在/答卷不存在",原先"未发布/停用"这类差别信息只在通过读权判定之后才会给出。
§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 必须随投递下发,
消费方据此识别"这份授权已作废"。撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。