按 Prime Directive #10 记录。#3545 复查(PR #3748)时顺带查了 #3391 家族里同一类论证结构的其它前提 —— 「这层放行没问题,因为另一层会兜住」。找到一条不成立的。
前提
packages/rest/src/rest-server.ts:1141,enforceApiAccess 里逐字:
if (!obj) return false; // unknown object → let the data path 404
对象不在元数据 items 里 → 跳过整个 API 曝露 gate,把兜底交给 data path 的 404。
前提不成立
① findData 没有存在性校验。 全仓 OBJECT_NOT_FOUND 只有一处抛出(packages/metadata-protocol/src/protocol.ts:2931),在 cloneData 里;背书它的测试(objectql/src/protocol-data.test.ts:589)测的也是 cloneData。findData 直接 this.engine.find(request.object, options),中间无校验。
② 引擎对未注册对象不拒绝,拿 name 当表名(engine.ts:1693 resolveObjectName):
const schema = this._registry.getObject(name);
if (schema) return StorageNameMapping.resolveTableName(schema);
return StorageNameMapping.resolveTableName({ name }); // ← 未注册:name 即表名
③ 404 只靠匹配驱动错误字符串(rest-server.ts:334-341:no such table / relation ... does not exist / …)。也就是说 404 是驱动报错的副产品,不是 data path 的性质 —— 驱动不报错就没有 404。
实证
真实 ObjectQL 引擎 + 真实 protocol.findData(list 路由实际调用的那个),内存驱动:
| 案例 |
结果 |
| A — 对象未注册,物理表也不存在 |
threw: false → 返回 200 [],不是 404 |
| B — 物理表存在但对象未注册 |
threw: false → 返回数据 { id: 'r1', secret: 'classified' } |
案例 A 依赖驱动:SQL 驱动会报 no such table → 经 ③ 映射成 404,所以 A 在生产上通常确实是 404。案例 B 与驱动无关,是真正的缺口:元数据注册表与物理表集合一旦发散(带外建表、注册竞态、注册失败但表已 sync),曝露 gate 被静默跳过,且无任何一层把它变成 404。
现状风险(已被 #3545 修复大幅收窄)
PR #3748 之后,getObjectSecurityMeta 对不可解析对象返回 unresolved,中间件 fail-closed。所以:
- ✅ 已装 plugin-security + 已认证且已解析出授权的调用方 → 现在被拒(姿态不可解析)。这条路已经关上了。
- ⚠️ 匿名 / 无主体上下文 → 中间件在姿态检查之前就短路(
positions/permissions 皆空且无 userId → return next()),不受保护。实际可达性取决于部署是否放行匿名读(enforceAuth)。
- ⚠️ 未装 plugin-security 的部署 → 根本没有中间件。
所以当前真正兜住这件事的是 #3545 的修复,不是注释里说的那个 404。这正是要记一笔的原因:注释在引导后来者依赖一个不存在的兜底,下一个人很可能据此做出错误的放宽决定。
建议(未做决策,留给维护者)
- 最小且诚实:改掉那句注释 —— 说明放行的真实理由是「未注册对象没有可执行的曝露策略,由安全中间件的姿态 fail-closed 兜底」,而不是「data path 会 404」。
- 收紧:给
findData(及同类无校验的 data 入口)加与 cloneData 一致的注册表存在性校验 → 统一 OBJECT_NOT_FOUND 404。好处是不再依赖驱动错误字符串匹配(那本身就脆:换驱动、换语言环境就可能失配),坏处是要确认没有合法路径依赖「查询未注册对象」。
- 兼顾匿名面:若采纳 2,案例 B 对匿名与无 plugin-security 部署也一并关闭。
倾向 1 + 2:2 才是把「declared ≠ enforced」真正收口,1 无论如何都该做。
同批复查中成立的前提(记录备查)
关联:#3545、#3391、#3498、PR #3748、ADR-0049。
按 Prime Directive #10 记录。#3545 复查(PR #3748)时顺带查了 #3391 家族里同一类论证结构的其它前提 —— 「这层放行没问题,因为另一层会兜住」。找到一条不成立的。
前提
packages/rest/src/rest-server.ts:1141,enforceApiAccess里逐字:对象不在元数据 items 里 → 跳过整个 API 曝露 gate,把兜底交给 data path 的 404。
前提不成立
①
findData没有存在性校验。 全仓OBJECT_NOT_FOUND只有一处抛出(packages/metadata-protocol/src/protocol.ts:2931),在cloneData里;背书它的测试(objectql/src/protocol-data.test.ts:589)测的也是cloneData。findData直接this.engine.find(request.object, options),中间无校验。② 引擎对未注册对象不拒绝,拿 name 当表名(
engine.ts:1693resolveObjectName):③ 404 只靠匹配驱动错误字符串(
rest-server.ts:334-341:no such table/relation ... does not exist/ …)。也就是说 404 是驱动报错的副产品,不是 data path 的性质 —— 驱动不报错就没有 404。实证
真实
ObjectQL引擎 + 真实protocol.findData(list 路由实际调用的那个),内存驱动:threw: false→ 返回200 [],不是 404threw: false→ 返回数据{ id: 'r1', secret: 'classified' }案例 A 依赖驱动:SQL 驱动会报
no such table→ 经 ③ 映射成 404,所以 A 在生产上通常确实是 404。案例 B 与驱动无关,是真正的缺口:元数据注册表与物理表集合一旦发散(带外建表、注册竞态、注册失败但表已 sync),曝露 gate 被静默跳过,且无任何一层把它变成 404。现状风险(已被 #3545 修复大幅收窄)
PR #3748 之后,
getObjectSecurityMeta对不可解析对象返回unresolved,中间件 fail-closed。所以:positions/permissions 皆空且无 userId→return next()),不受保护。实际可达性取决于部署是否放行匿名读(enforceAuth)。所以当前真正兜住这件事的是 #3545 的修复,不是注释里说的那个 404。这正是要记一笔的原因:注释在引导后来者依赖一个不存在的兜底,下一个人很可能据此做出错误的放宽决定。
建议(未做决策,留给维护者)
findData(及同类无校验的 data 入口)加与cloneData一致的注册表存在性校验 → 统一OBJECT_NOT_FOUND404。好处是不再依赖驱动错误字符串匹配(那本身就脆:换驱动、换语言环境就可能失配),坏处是要确认没有合法路径依赖「查询未注册对象」。倾向 1 + 2:2 才是把「declared ≠ enforced」真正收口,1 无论如何都该做。
同批复查中成立的前提(记录备查)
apiMethods非数组 → fail-closed):成立。resolveEffectiveApiMethods现解析为deny-all,即便可达也是关的。getReadableFieldsfail-soft):成立且契约写得很清楚 ——undefined= 没答案(调用方自己兜底),[]= 真答案「一列都不可读」;消费方rest-server.ts:4427if (Array.isArray(readable))处理正确。其「投影只是在已发生的强制之上做装饰性收窄」这一理由,在 元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545 修复后更强(姿态不可解析时整个读已被拒)。关联:#3545、#3391、#3498、PR #3748、ADR-0049。