Skip to content

fix(analytics): 记录级权限范围与 measure 字段校验 (#4467, #4437) - #4494

Merged
os-zhuang merged 3 commits into
mainfrom
claude/v17-verification-defects-gnf9e6-analytics
Aug 1, 2026
Merged

fix(analytics): 记录级权限范围与 measure 字段校验 (#4467, #4437)#4494
os-zhuang merged 3 commits into
mainfrom
claude/v17-verification-defects-gnf9e6-analytics

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

v17 验收缺陷清单 #4482 的第一批修复。两个缺陷都先在真实 dev server 上复现,再修,再用同一组请求验证修好

同批的 #4475 根因落在 Console 侧,修复在 objectui 同名分支上(见文末"关联")。


#4467/analytics/query 完全不施加记录级 scoping(RC 阻塞项)

复现(showcase,pnpm dev -- --fresh -p 38101,两个真实主体)

showcase_private_notesharingModel: 'private'。admin 拥有 5 条,member 持有其中 2 条的 read share、任何地方都没有 viewAllRecords

GET  /data/showcase_private_note           admin  → total 5
GET  /data/showcase_private_note           member → total 2          ✅ OWD + shares
POST /analytics/query {measures:["count"]} admin  → [{"count":5}]
POST /analytics/query {measures:["count"]} member → [{"count":5}]    ❌ 多数了 3 条读不到的
POST /analytics/query + dimensions:["title"]
     member → ['rc1 rule target','rc1 secret','rls-probe-1','rls-probe-2','rls-probe-3']  ❌
sql: SELECT title AS "title", COUNT(*) AS "count" FROM "showcase_private_note" GROUP BY title

dimensions 那一条才是要害:它回吐的是列的取值本身,不只是一个数。

根因

ISecurityService.getReadFilter 的契约原文写得很清楚——"the same filter the engine middleware AND-s into every find",存在的意义就是给绕过 middleware 的路径(注释里点名 analytics raw-SQL 路径)用。

但那条 middleware 链其实是两个并列的兄弟中间件:

  1. plugin-security 的 RLS 注入;
  2. plugin-sharing 的 owner / share 可见性过滤(buildSharingMiddlewarefind/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 所达成的同一个组合。三个细节:

  • 深度要自己补算。 plugin-sharing 靠 __readScope(ADR-0057 D1)决定 owner-match 放宽到 own / unit / org,而那是 security 中间件 stash 到 context 上的。这条路径上没有中间件跑,所以这里用和中间件完全相同的那次 getEffectiveScope 调用算出来。不补的话,被授予 unit/org 读深度的调用者在 analytics 里会被静默收窄到 own
  • 在 RLS 的各个 early-return 之前解析。 RLS 那半的 stand-down 分支都是 RLS 中间件自己 的提前退出,没有一个构成丢掉兄弟中间件谓词的理由
  • fail-closed。 sharing 解析失败直接返回 deny sentinel,绝不退化成"只有 RLS"的半个 scope。

✅ 真实验证结论(修复后,同一组请求、同样两个主体)

GET  /data/showcase_private_note           admin  → total 5
GET  /data/showcase_private_note           member → total 2
POST /analytics/query {measures:["count"]} admin  → [{"count":5}]     ✅ 未受影响
POST /analytics/query {measures:["count"]} member → [{"count":2}]     ✅ 与 /data 一致
sql: SELECT COUNT(*) AS "count" FROM "showcase_private_note"
     WHERE (("showcase_private_note"."owner_id" = $1
             OR "showcase_private_note"."id" IN ($2, $3)))

POST /analytics/query + dimensions:["title"]
     member → ['rc1 rule target', 'rc1 secret']                       ✅ 只剩有权读的两条

count 从 5 变成 2;dimensions:["title"] 不再回吐那 3 条读不到的标题。 编译出的 SQL 里现在带着 owner/share 谓词——正是 issue 指出"从来没有出现过"的那一条。

回归测试

security-plugin.test.tsgetReadFilter service 下新增 [#4467] OWD / sharing composition(6 例):与 RLS 的 AND 组合、RLS 无贡献时 sharing 谓词单独存活、__readScope 深度确实透传、sharing 抛错 → RLS_DENY_FILTERisSystem 两半都绕过、没装 plugin-sharing 时行为不变。


#4437 — measure 指向不存在的字段时 500 SQLITE_ERROR

复现

POST /analytics/query {"cube":"showcase_invoice","measures":["ghost_sum"]}
→ 500 {"code":"SQLITE_ERROR","message":"Internal server error","httpStatus":500}

POST /analytics/query {"cube":"showcase_invoice","measures":["total.sum"]}
→ 500 {"code":"SQLITE_ERROR", ...}     # 点号写法 prefix-strip 成 sum → SUM(sum)

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

✅ 真实验证结论(修复后)

POST /analytics/query {"cube":"showcase_invoice","measures":["ghost_sum"]}
→ 400 {"code":"INVALID_FIELD","message":
   "Measure 'ghost_sum' on cube 'showcase_invoice' aggregates field 'ghost',
    which object 'showcase_invoice' does not have. Valid measures: count.
    Other measures are inferred from the object's OWN fields as '<field>_sum' /
    '_avg' / '_min' / '_max' / '_count_distinct', so check the spelling of
    'ghost' — known fields: __search, account, contact, created_at, …, total, …"}

POST /analytics/query {"cube":"showcase_invoice","measures":["total.sum"]}
→ 400 INVALID_FIELD,message 里指名 'sum'

# 对照组仍然正常
{"measures":["total_sum"]} → 200  SELECT SUM(total) …  = 2567.9
{"measures":["count"]}     → 200  SELECT COUNT(*) …    = 12

已确认返回指名该字段的 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 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 运行中,结果将在下方评论补充
真实启动验证 ✅ 已完成(证据见上),服务器已收摊

未触及 packages/spec,因此不需要 check:generated

范围与协议边界

  • 没有移除或重命名任何可作者化的 spec key;
  • 没有改动任何公开导出的签名或返回类型(ISecurityService.getReadFilter声明不变,变的是实现终于兑现了它已写下的契约);
  • 没有协议层破坏性变更。

需要维护者留意的一点(非阻塞)

#4437 的闸门只覆盖 measure,因为 issue 就是按 measure 报的。dimensions 指向不存在字段是否也同样 500,未在本 PR 范围内展开——若确认存在,按 Prime Directive #10 应另开 issue,而不是在这里扩大范围。

关联

claude added 2 commits August 1, 2026 10:49
#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
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 1, 2026 12:10pm

Request Review

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/service-analytics.

17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security, @objectstack/service-analytics)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security, @objectstack/service-analytics)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security, @objectstack/service-analytics)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security, @objectstack/service-analytics)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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
@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

闸门全部完成 ✅

正文里"pnpm typecheck 运行中"那一行现在有结论了,补齐如下。

CI(head c3cc106,mergeable_state: clean)

检查 结论
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 正文里,这里摘要:

两项都没有改动任何 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

@os-zhuang
os-zhuang marked this pull request as ready for review August 1, 2026 12:56
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 1, 2026
Merged via the queue into main with commit 328ccc5 Aug 1, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/v17-verification-defects-gnf9e6-analytics branch August 1, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants