Skip to content

fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654) - #3664

Merged
os-zhuang merged 1 commit into
mainfrom
fix/objectql-crossobj-3654
Jul 27, 2026
Merged

fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654)#3664
os-zhuang merged 1 commit into
mainfrom
fix/objectql-crossobj-3654

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#3654(止血部分)。把静默出错的跨对象聚合从"塌成 (null) 桶"变成响亮拒绝。

问题(#3654)

engine.aggregate()没有 join —— 不展开 lookup,SQL driver 的 aggregate 也不发 JOIN。于是点号维度/度量(如 account.region)到了 ObjectQLStrategy(NativeSQL 声明失败时的回退:日期分桶 / in-memory driver / federated)就静默出错:

  • in-memory:row['account.region'] 平铺取值恒 undefined → 所有行塌进一个 (null),度量被全表加总 —— 数字看着有值,其实是错标成 (null) 的全表汇总;
  • native SQL:未解析列 → 报错。

已用 applyInMemoryAggregation 纯函数在 #3654 里复现坐实。

修法(fail-closed 止血)

ObjectQLStrategy 在查询到达引擎之前,无条件拒绝任何跨对象引用,给出清晰错误。

泛化并涵盖了 #3601 的 guard(assertJoinedScopesEnforceable):旧 guard 只在被联对象带 read scope 时拒绝,且无 read-scope provider 时直接早返回 —— 所以无安全 / in-memory 环境下静默 (null) 照旧发生。新 guard 无条件,且天然涵盖 #3597 的安全关切:被拒绝的查询从不加载联表 → 没有未加 scope 的联表数据可泄露。

检测基于解析后的字段名(resolveFieldName 之后),所以被 cube 拍平成真实列的点号维度不误伤,只拦真正未解析的关系遍历。

跨对象数据集在 NativeSQLStrategy 上不受影响(它手编 LEFT JOIN 且逐 join 加 scope)。本 PR 只改回退路径:静默错答 → 响亮报错。aggregate 路径的完整 lookup 遍历能力留作后续(#3654 方向 1)。

测试

三个跨对象用例现在都拒绝(经真实 AnalyticsService.query() → 策略选择 → guard;executeAggregate stub 故意"不拦就返回垃圾 (null) 桶",所以通过=证明拒绝):

回退 guard → 三个全红。service-analytics 185 passed;tsc 无新增错误(2 存量在未触文件)。路由(granularity 让 NativeSQL 退场 → ObjectQL)已被现有 analytics-rls.dogfood.test.ts 的 ObjectQL 用例覆盖。

🤖 Generated with Claude Code

…path cannot join (#3654)
engine.aggregate() has no join — it never expands a lookup and the SQL
driver's aggregate emits no JOIN. A dotted dimension/measure like
account.region reaching ObjectQLStrategy (the fallback NativeSQL declines
on: date-granularity bucketing, in-memory driver, federated objects)
failed SILENTLY: the in-memory path bucketed every row under one (null)
group and summed the whole table into it — a plausible number that is
actually a mislabelled full-table total; the native path errored on the
unresolved column.
ObjectQLStrategy now rejects any cross-object reference outright, with a
clear message, before the query reaches the engine. This generalizes the
#3597 guard (assertJoinedScopesEnforceable), which only rejected when the
joined object carried a read scope AND early-returned when no read-scope
provider was configured — so the silent (null) bucket still shipped on
unsecured / in-memory setups. The new guard is unconditional and subsumes
#3597: a rejected query never loads the joined object, so nothing is left
unscoped.
Cross-object datasets are unaffected on NativeSQLStrategy, which
hand-compiles the LEFT JOINs (and scopes each). This only changes the
fallback path — a silent wrong answer becomes a loud, actionable error.
Full lookup-traversal in the aggregate path is left as follow-up (#3654).
Tests: three cross-object cases (joined-object scope / base-only scope /
NO provider — the #3654 silent-(null) case) now reject; reverting the
guard turns all three red. The engine-level in-memory repro is in #3654.
Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercelBot commented Jul 27, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 27, 2026 1:23pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): packages/services.

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

  • content/docs/automation/webhooks.mdx(via packages/services)
  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/index.mdx(via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services)
  • content/docs/plugins/packages.mdx(via packages/services)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/services)

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.

@os-zhuang
os-zhuang merged commit adabaa8 into mainJul 27, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the fix/objectql-crossobj-3654 branch July 27, 2026 13:35
os-zhuang pushed a commit that referenced this pull request Jul 27, 2026
#3664 replaced `assertJoinedScopesEnforceable` with
`assertNoCrossObjectReferences`, which rejects any cross-object
reference regardless of read scope. That is strictly better than what
this branch had: `engine.aggregate()` has no join and the SQL driver's
aggregate emits none, so a dotted member was never merely "unscopeable"
— it was silently wrong (one `(null)` bucket) or a hard error. Adopted
as-is; this branch's narrower scope-conditional guard is gone.
Made it derive its field set from `referencedFieldNames(cube, query)`
instead of `execute()`'s built `groupBy`/`filter`, so `generateSql()`
runs the IDENTICAL guard — the rendered SQL must not describe a query
`execute()` would reject. Same field set either way, so `execute()`'s
behaviour is unchanged.
Also kept from this branch: the read scope rendered into `generateSql`'s
WHERE (#3664 left rendering alone), and the `context` forwarded to the
aggregate bridge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013st2KArjmLQSuVzpDS91jj
xuyushun441-sys pushed a commit that referenced this pull request Jul 27, 2026
…#3654)
The ObjectQL fallback path (date-granularity bucketing, in-memory driver,
federated) could not join, so cross-object grouping like
`revenue by account.region` was rejected (#3664 stopgap). It now serves
the common case by FK-expand:
1. group the base aggregate on the lookup FK column (`account`) — which
the engine CAN do — scoped to the base object;
2. resolve each FK id to the related attribute (`region`) with a read of
the referenced object scoped to THAT object's own RLS;
3. re-bucket by the resolved attribute in memory, recombining measures
(sum/count add, min/max take the extremum).
A base row whose referenced record the caller cannot read buckets under
an explicit `(restricted)` group: the measure still counts (grand totals
preserved) but the hidden attribute never appears — no leak (ADR-0021
D-C / #3602). `/analytics/sql` renders the equivalent LEFT JOIN.
Bounded — still rejected LOUD (never silently wrong): cross-object in a
MEASURE or FILTER, multi-hop dims, and non-recombinable measures
(avg/count_distinct) with a cross-object dim. NativeSQLStrategy
(the normal SQL path) is unchanged.
The pure re-bucketing step is isolated in cross-object-rebucket.ts and
exhaustively unit-tested (recombination, restricted-total conservation,
null-vs-restricted, multi-dim). Integration tests drive the real
AnalyticsService with a two-call aggregate stub, asserting both the base
and the FK-resolution are scoped and the restricted bucket forms;
reverting the strategy turns all three red.
Co-Authored-By: Claude <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 27, 2026
…#3654) (#3691)
The ObjectQL fallback path (date-granularity bucketing, in-memory driver,
federated) could not join, so cross-object grouping like
`revenue by account.region` was rejected outright (#3664 stopgap). It now
serves the common case by FK-expand:
1. group the base aggregate on the lookup FK column (`account`) — which
the engine CAN do — scoped to the base object (and, per #3651's second
belt, threading the ExecutionContext to the engine too);
2. resolve each FK id to the related attribute (`region`) with a read of
the referenced object scoped to THAT object's own RLS;
3. re-bucket by the resolved attribute in memory, recombining measures
(sum/count add, min/max take the extremum).
A base row whose referenced record the caller cannot read buckets under
an explicit `(restricted)` group: the measure still counts (grand totals
preserved) but the hidden attribute never appears — no leak (ADR-0021
D-C / #3602). `/analytics/sql` renders the equivalent LEFT JOIN, so
preview and execution accept/reject the same set.
Bounded — still rejected LOUD (never silently wrong): cross-object in a
MEASURE or FILTER, multi-hop dims, and non-recombinable measures
(avg/count_distinct) with a cross-object dim. NativeSQLStrategy is
unchanged.
The pure re-bucketing step is isolated in cross-object-rebucket.ts and
exhaustively unit-tested (recombination, restricted-total conservation,
null-vs-restricted, multi-dim). Integration tests drive the real
AnalyticsService with a two-call aggregate stub, asserting both the base
and FK-resolution are scoped and the restricted bucket forms; reverting
the strategy turns all three red.
Re-applied cleanly on top of #3651/#3652 (executeAggregate carries
context; generateSql renders scope) after those landed on main.
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics/objectql: 跨对象(点号 lookup)维度聚合在 ObjectQL 路径上静默塌成 (null) 桶 / native SQL 报错

1 participant

@os-zhuang