fix(security): 元数据不可解析时安全姿态 fail-closed —— #3545 那条前提本身不成立 - #3748
Merged
Conversation
… resolved (#3545) #3545 assessed the API-exposure gate's fail-open on unresolvable metadata and accepted it, resting on one load-bearing premise: the gate is a SURFACE-AREA control, while the real authorization boundary — auth + the ObjectQL security middleware (CRUD/FLS/RLS) — enforces unconditionally on the data call whatever the gate answers. Verifying that premise instead of assuming it shows it did not hold. The middleware does run unconditionally, but two of its INPUTS were read from the same object metadata and defaulted permissively when it could not be resolved, so the very trigger this issue is about reached one layer PAST the gate, into the boundary itself: * `access.default: 'private'` -> `isPrivate` defaulted to false. ADR-0066 D2 deliberately excludes a private object from a plain (non-superuser) `'*'` wildcard; read as public it IS covered — a grant nobody authored. * `requiredPermissions` -> defaulted to `[]`, which skips the ADR-0066 D3 capability AND-gate entirely (`if (required.length > 0)`). `getObjectSecurityMeta` now flags `unresolved`, and the three consumers that turn posture into an access decision fail closed on it: the middleware denies (with an error log, so a persistent metadata outage is observable rather than a silent blanket-allow), `canExport` denies, `getReadableFields` exposes no columns — the same stance already taken for a permission-resolution failure and a dangling delegator. `computeLayeredRlsFilter` keeps consuming the defaults on purpose: there the permissive value WITHHOLDS the cross-tenant exemption, so it is already the closed direction. Blast radius is bounded to the risky case. System/boot writes (`isSystem`) and principal-less/anonymous contexts short-circuit earlier in the middleware, so reaching the new check means an authenticated principal with resolved grants asking for an object whose declaration is missing. The cold-start window is served by those short-circuits, not by the permissive default — which is why the tiered decision recorded for the exposure gate (transient unavailability -> fail open) stands unchanged now that the boundary underneath it actually holds. The explain engine reports the denial on its existing `object_crud` layer naming the real cause, so the "why am I denied?" surface cannot drift from enforcement. Regression-pinned in metadata-unresolvable-posture.test.ts: both directions of each axis (resolvable -> denied per ADR-0066; unresolvable -> still denied), the unaffected anonymous / system / public-object paths, the error log, and explain parity. Verified green: plugin-security (645), runtime, rest, objectql, and the dogfood suite that boots the real showcase app (369). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019288JyDfHtCiYVgAJLxmsg
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 26 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
3 tasks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019288JyDfHtCiYVgAJLxmsg
os-zhuang
marked this pull request as ready for review
July 28, 2026 02:07
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3545 的核心风险项。
背景:上一轮结论依赖一条未被验证的前提
#3545 上一轮(PR #3567)评估了 API 曝露 gate 在元数据不可解析时的 fail-open,结论是残余风险 = 低。这个结论完全建立在一条前提上:
这条前提是 load-bearing 的,所以本轮去验证它,而不是接着假设。结果:它不成立。
发现:同一个触发条件穿透到了边界内部
中间件确实无条件运行 —— 但它的两个输入读自同一份对象元数据,并且在读不到时向宽松端默认。于是 #3545 关心的那个触发条件(元数据不可解析)绕过了曝露 gate 之后,又穿透了下面这层边界:
access.default: 'private'isPrivate: false'*'通配授权覆盖;被当成 public 后就被覆盖了 —— 一条没有任何人授予过的权限requiredPermissions[]if (required.length > 0)被整段跳过这不是理论路径。新增的回归用例先跑对照组(元数据可解析 → 按 ADR-0066 拒绝),再跑同一场景下元数据不可解析 —— 修复前两条都放行:一个只持有普通通配授权、不持有
manage_platform_settings的普通成员,既读到了 private 对象,也读到了声明了能力契约的对象。改动
getObjectSecurityMeta现在标记unresolved,三个把姿态转成访问决策的消费方一律 fail-closed:logger.error(持续性元数据故障可观测,而不是静默全量放行)canExport→ 拒绝getReadableFields→ 不暴露任何列与该文件既有的 fail-closed 立场一致(权限解析失败、悬空 delegator)。
computeLayeredRlsFilter有意保持消费默认值:在那里宽松默认恰好是扣留跨租户豁免,已经是收紧方向 —— 一并改成"最严"反而会引入一个反方向的 fail-open。explain引擎在既有object_crud层报出真实原因,避免"为什么被拒"的解释面与执行面漂移(不新增 layer 类型,不动 spec)。影响范围
严格限定在有风险的那一档。
isSystem(系统/启动写入)与无主体/匿名上下文在中间件更早处就短路了,所以走到新检查意味着:一个已认证、已解析出授权的主体,请求一个声明缺失的对象。因此冷启动窗口不会被误伤 —— 那个窗口靠的是前面的短路,不是这个宽松默认。这正好回答了 issue 里"fail-closed 会不会在正常启动窗口误伤流量"这一问:不会。也因此,曝露 gate 那一侧的分级决策(瞬态不可用 → fail-open)维持不变 —— 现在它下面的边界是真的兜得住了。
api-exposure.ts里的 #3545 决策记录已相应更正。验证
metadata-unresolvable-posture.test.ts:每条轴的两个方向(可解析 → 按 ADR-0066 拒绝;不可解析 → 仍然拒绝)、不受影响的匿名/系统/public 对象路径、错误日志、explain 一致性plugin-security645 passed ·runtime/rest/objectql全绿(28 tasks)遗留
issue 里 ③ 那条(
apiMethods非数组 → fail-closed)已在 #3543 落地,resolveEffectiveApiMethods现已解析为deny-all。Generated by Claude Code