fix(analytics): 记录级权限范围与 measure 字段校验 (#4467, #4437) - #4494
Conversation
#4437) Two of the three v17 verification defects on the analytics query path. Both reproduced live on a showcase dev server before the change and re-verified after; regression tests still to be added (hence wip). #4467 — /analytics/query ignored record-level scoping `ISecurityService.getReadFilter` documents itself as "the same filter the engine middleware AND-s into every find", exposed for paths that bypass the middleware (the analytics raw-SQL path has no other source of scope). That middleware chain is TWO siblings: plugin-security's RLS injection and plugin-sharing's owner/share visibility filter. Only the RLS half was ever computed, so the analytics path ran with no owner predicate at all. Live repro (showcase, `showcase_private_note` sharingModel:'private', admin owns 5, member holds 2 shares and no viewAllRecords): GET /data/showcase_private_note member -> total 2 correct POST /analytics/query {measures:[count]} member -> count 5 LEAK ... + dimensions:["title"] member -> all 5 titles getReadFilter now resolves plugin-sharing's buildReadFilter through the late-bound `sharing` service and AND-composes it with the RLS filter, and computes the ADR-0057 D1 `__readScope` depth the middleware normally stashes on the context (no middleware runs on this path). Resolved for every non-system caller ahead of the RLS branches — none of the RLS stand-downs is a reason to drop a sibling middleware's predicate — and a resolution failure denies rather than emitting unscoped SQL. #4437 — a measure naming a missing field 500'd with SQLITE_ERROR `inferMeasure('ghost_sum')` built `SUM(ghost)` with no way to know the field exists; the driver threw `no such column` and the caller got `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error class on the wire for a plain typo (ADR-0112). The DATA route has refused the same mistake with a 400 naming the field since #4315/#4254. `ensureCube` now validates each measure's resolved source field against the backing object's field names before any SQL is built, and rejects with the same envelope the data route uses (400 INVALID_FIELD + field/object/param). Gated the same way as the #3867 inference gate: only for a cube whose `sql` is a bare object name, only when the new `getObjectFieldNames` probe answers, and only for measures whose source is a bare column (count(*) and dotted cross-object references pass through). Validation runs before the cube is registered so a rejected query leaves no trace in the registry. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
Regression cases for the two fixes in the previous commit, plus a polish to the #4437 rejection message. #4467 — `security-plugin.test.ts` gains an OWD/sharing block under the existing `getReadFilter service` describe: AND-composition with the RLS filter, the sharing predicate surviving alone when RLS contributes nothing, the ADR-0057 D1 `__readScope` depth being passed (no middleware runs on this path to stash it), fail-closed on a sharing-resolution throw, the isSystem bypass, and a deployment without plugin-sharing being unaffected. The harness gains an optional `sharing` service double. #4437 — a new `measure-source-field-gate.test.ts` covering the 400 envelope and its `field`/`object`/`param`/`measure` members, the dotted `total.sum` spelling, registry non-poisoning, every legitimate measure spelling still running, an authored cube whose declared measure lost its field, and the three stand-downs (no probe, an object the probe cannot describe, and a cube whose `sql` is an expression rather than an object name). A dotted cross-object measure is asserted to reach the STRATEGY — the layer that owns that decision — rather than being reported as a missing column here. Polish: the rejection listed the caller's own typo as a valid alternative on the auto-inference path, because `cube.measures` there was inferred from the very query being rejected. The suggestion list now excludes measures that failed the check, and names the object's known fields. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…4467, #4437) Both packages are publishable and both changes are observable on a public surface, so this is a real changeset rather than an empty one. Levelled `minor` on both counts. #4467 narrows a public read surface — analytics results a principal could previously read they now cannot, so counts drop and `dimensions` groupings lose rows for non-superuser callers on owner-private objects. #4437 changes the response envelope for a caller-shaped mistake (500 SQLITE_ERROR → 400 INVALID_FIELD), which any caller branching on `error.code` will observe. Neither changes an API signature: `ISecurityService.getReadFilter`'s declaration is untouched, and the implementation merely started honouring the contract it already documented. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
闸门全部完成 ✅正文里" CI(head
|
| 检查 | 结论 |
|---|---|
| TypeScript Type Check | ✅ success |
| Test Core | ✅ success |
| Build Core | ✅ success |
| ESLint | ✅ success |
| Check Changeset | ✅ success |
| Check PR Size | ✅ success |
| Temporal Conformance (live PG + MySQL) | ✅ success |
| Dogfood Regression Gate (2/2) | ✅ success |
| Dogfood Regression Gate (1/2) | ✅ success |
| Console Pin Gate / Build Docs | skipped(未触及相关路径) |
没有失败项,也没有需要归因到 main 既有失败的项。
本地闸门
| 项 | 结果 |
|---|---|
pnpm --filter @objectstack/service-analytics test |
36 files / 488 passed |
pnpm --filter @objectstack/plugin-security test |
32 files / 683 passed |
pnpm --filter @objectstack/plugin-sharing test |
11 files / 226 passed |
pnpm typecheck |
0 个 error TS;CI 上同名 job 独立跑通 |
plugin-sharing 不在改动包内,但 #4467 的修复要跨进程消费它的 buildReadFilter,所以一并跑了它的套件确认没有反向影响。
关于 changeset
按门禁要求补了 .changeset/analytics-record-scoping-and-measure-fields.md,两个包都是真实条目:
"@objectstack/plugin-security": minor
"@objectstack/service-analytics": minor
两项都定 minor 而非 patch,理由写在 changeset 正文里,这里摘要:
- analytics: /analytics/query ignores record-level scoping — a member counts and reads dimension values of records they cannot read #4467 是可观察的行为收窄——原本能读到的 analytics 结果现在读不到了。非超级用户在 owner-private 对象上的 count 会变小、
dimensions分组会少行,任何建立在/analytics/query之上的 dashboard / 报表 / 导出都会在升级后看到数字变化(变正确,但可见)。哪怕这是安全修复,也不该只按 patch 记一笔。 - analytics: a measure naming a missing field 500s with SQLITE_ERROR instead of a 400 naming the field #4437 改的是响应信封——同一个请求从
500 SQLITE_ERROR变成400 INVALID_FIELD,任何按error.code分支的调用方都会观察到。
两项都没有改动任何 API 签名:ISecurityService.getReadFilter 的声明原封不动,变的只是实现终于兑现了它文档里早就写下的契约。
真实启动验证
已完成并收摊(kill $(lsof -ti tcp:38101)),证据在正文的两个"✅ 真实验证结论"小节——最关键的一条是:对 5 条私有记录只持有 2 条 share 的 member,count 从 5 变成 2,dimensions:["title"] 不再回吐那 3 条读不到的标题,且编译出的 SQL 里出现了此前从未出现过的 owner/share 谓词。
Generated by Claude Code
v17 验收缺陷清单 #4482 的第一批修复。两个缺陷都先在真实 dev server 上复现,再修,再用同一组请求验证修好。
同批的 #4475 根因落在 Console 侧,修复在 objectui 同名分支上(见文末"关联")。
#4467 —
/analytics/query完全不施加记录级 scoping(RC 阻塞项)复现(showcase,
pnpm dev -- --fresh -p 38101,两个真实主体)showcase_private_note是sharingModel: 'private'。admin 拥有 5 条,member 持有其中 2 条的 read share、任何地方都没有viewAllRecords。dimensions那一条才是要害:它回吐的是列的取值本身,不只是一个数。根因
ISecurityService.getReadFilter的契约原文写得很清楚——"the same filter the engine middleware AND-s into every find",存在的意义就是给绕过 middleware 的路径(注释里点名 analytics raw-SQL 路径)用。但那条 middleware 链其实是两个并列的兄弟中间件:
buildSharingMiddleware对find/findOne/count/aggregate都会把buildReadFilter的结果 AND 进ast.where)。而
SecurityPlugin.getReadFilter只算了第 1 半(computeRlsFilter)。analytics 这条路没有第二个 scope 来源,于是 OWD/share 谓词从头到尾就没存在过。这是实现违背了自己已经写下的契约,不是 spec 需要改——不涉及任何协议层破坏性变更。
改动
getReadFilter现在通过 late-bound 的sharing服务解析buildReadFilter,与 RLS filter 用现成的andComposeLayers做 AND 组合——正是两个中间件各自写进ast.where所达成的同一个组合。三个细节:__readScope(ADR-0057 D1)决定 owner-match 放宽到 own / unit / org,而那是 security 中间件 stash 到 context 上的。这条路径上没有中间件跑,所以这里用和中间件完全相同的那次getEffectiveScope调用算出来。不补的话,被授予unit/org读深度的调用者在 analytics 里会被静默收窄到own。✅ 真实验证结论(修复后,同一组请求、同样两个主体)
count 从 5 变成 2;
dimensions:["title"]不再回吐那 3 条读不到的标题。 编译出的 SQL 里现在带着 owner/share 谓词——正是 issue 指出"从来没有出现过"的那一条。回归测试
security-plugin.test.ts的getReadFilter service下新增[#4467] OWD / sharing composition(6 例):与 RLS 的 AND 组合、RLS 无贡献时 sharing 谓词单独存活、__readScope深度确实透传、sharing 抛错 →RLS_DENY_FILTER、isSystem两半都绕过、没装 plugin-sharing 时行为不变。#4437 — measure 指向不存在的字段时 500 SQLITE_ERROR
复现
inferMeasure('ghost_sum')照后缀约定拼出SUM(ghost),无从知道ghost是否真字段;driver 抛no such column,调用者拿到一个 driver 错误类当error.code——对纯粹的拼写错误既不可行动,也违反 ADR-0112。data route 早在 #4315/#4254 就用400 INVALID_FIELD+ 指名字段回答了同一个错误。改动
AnalyticsService.ensureCube在任何 SQL 被构造之前核对每个 measure 的源字段,并用与 data route 完全相同的信封拒绝(400 INVALID_FIELD,携带field/object/param/measure)。门槛沿用 #3867 cube 推断闸门的分级,刻意不越界:只在 cube 的sql是裸对象名时检查、只在getObjectFieldNames探针能回答时检查、只检查源是裸列名的 measure(count(*)与account.industry这类跨对象引用放行)、id/created_at/updated_at无条件放行(与 data path 的resolveQueryFields对齐)。校验发生在 cube 注册之前,被拒绝的查询不会污染 registry。getObjectFieldNames读的是isRegisteredObject已在用、data path 的 #4315 闸门也在读的同一个 schema registry。✅ 真实验证结论(修复后)
已确认返回指名该字段的 400,而非 SQLITE_ERROR 500。
回归测试
新增
measure-source-field-gate.test.ts(12 例):400 信封与各字段成员、点号写法、registry 不被污染、所有合法拼法照常运行、authored cube 声明的 measure 丢了字段、三个 stand-down。跨对象点号 measure 断言它走到了 strategy并被那里以自己的理由拒绝,而不是在这里被误报成缺列。闸门
pnpm --filter @objectstack/service-analytics testpnpm --filter @objectstack/plugin-security testpnpm --filter @objectstack/plugin-sharing testpnpm typecheck未触及
packages/spec,因此不需要check:generated。范围与协议边界
ISecurityService.getReadFilter的声明不变,变的是实现终于兑现了它已写下的契约);需要维护者留意的一点(非阻塞)
#4437的闸门只覆盖 measure,因为 issue 就是按 measure 报的。dimensions指向不存在字段是否也同样 500,未在本 PR 范围内展开——若确认存在,按 Prime Directive #10 应另开 issue,而不是在这里扩大范围。关联
claude/v17-verification-defects-gnf9e6-analytics。根因是 Console 的 dashboard filter 归一化——System Overview 声明{field:'created_at', type:'date', defaultValue:'last_7_days'},而resolveDashboardFilterDefs只对内置dateRange把预设名提升成{preset},globalFilters分支原样透传,于是发出created_at = 'last_7_days'(实测 SQL 与 issue 一字不差),恒 0;同一个缺失也让选择器显示"全部时间"。framework 侧无需改动——给到真正的区间时 dataset 路径 lowering 是正确的(WHERE (created_at >= $1 AND created_at < $2)→ 4)。详见 Setup System Overview: every KPI tile reads 0 — the dataset path lowers an all-time range to WHERE created_at = $1 #4475 下的分析评论。