Repository navigation
finding: the #7929 read-scope disclosure is NOT analytics-only — an RLS $field filter injected by the security middleware discloses the same policy column on the ordinary CRUD path, and predates #7598 #7988
Description
Observation-class finding, filed unassigned with no pm:queue — for triage to grade and route. Measured while executing the #7929 measurement dispatch (domain:engine-core seat, 2026-08-12). Recording, not claiming.
Blocked-by: #7929
Why this is filed separately from #7929
#7929's body, the triage block, and the 2026-08-12 maintainer ruling on it all frame the disclosure as a consequence of #7598's Q1 = B routing — "the refusal moved to the driver along with the compilation" — and the ruling's implementation boundaries scope the pins accordingly:
Pin both directions: (a) a read scope carrying a driver-refused reference answers the withheld envelope with no field-name echo — extend the
analytics-query-read-scope-withholdpin family to this path
That framing is incomplete, and a fix scoped to it would leave the larger half open. The ordinary CRUD read path has the identical disclosure, and did not need #7598 to get it. It arrived with #5222 (2026-08-06), when driver-sql learned to compile { $field } and to refuse it with INVALID_FILTER / 400.
The two paths differ only in which component merges the admin's read filter into the query:
| path | merge boundary | file |
|---|---|---|
analytics POST /analytics/query |
ObjectQLStrategy.withReadScope |
packages/services/service-analytics/src/strategies/objectql-strategy.ts (withReadScope) |
| ordinary CRUD read | security middleware ANDs into opCtx.ast.where |
packages/plugins/plugin-security/src/security-plugin.ts:2198 |
Both hand the driver a bare FilterCondition with no provenance mark, and driver-sql's applyCrossFieldComparison refuses identically in both cases.
Measured
Real ObjectQL engine, real SqliteWasmDriver seeded with the shared CROSS_FIELD_* corpus fixture, and a read-filter middleware shaped exactly like the security/sharing ones (ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped) injecting an admin-authored policy predicate the caller never wrote:
scoped = { amount: { $gt: { $field: 'secret_policy_column' } } }
caller = ql.find('cross_field_deal', { where: { stage: 'won' } })
What the engine surfaces:
code: INVALID_FILTER
status: 400
message: Operator "$gt" on field "amount" compares against another field
({ "$field": "secret_policy_column" }), which cannot be compiled here:
"secret_policy_column" is not a declared field of "cross_field_deal" —
only declared fields can be referenced. Cross-field comparison on SQL
push-down supports same-table columns the object declares, compared as
the same type class, excluding the tenant-isolation column.
names the admin-authored column: true
Producer chain from the captured stack, verbatim:
unsupportedFilterError (driver-sql/src/sql-driver.ts:554)
uncompilableFieldReferenceError (driver-sql/src/sql-driver.ts:1138)
applyCrossFieldComparison (driver-sql/src/sql-driver.ts:9160)
applyFilterCondition (driver-sql/src/sql-driver.ts:8870)
The 400 is a declared 4xx, so the REST boundary's withhold — declaresServerFault, which requires status >= 500 — can never reach it. That tiering is deliberate and already pinned (analytics-query-read-scope-withhold.test.ts, the "a DECLARED 4xx is untouched — the withhold is 5xx-only" case), so this is not a boundary bug; it is the same attribution seam #7929 records, one path over.
Scope of the exposure
Requires an administrator to have authored a CEL permission/RLS rule with a field-to-field comparison — compileCelToFilter emits { $field: path } for exactly that — whose reference fails one of the four #5222 rulings (dotted path, undeclared column, the tenant-isolation column on either side, or a cross-class comparison). Narrow, and no customer report; but on the CRUD path it reaches every SQL-driver read, not just the analytics face.
Explicitly NOT claimed
- Not a regression, and not [spec]
service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598's doing. [spec]service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 Q1 = B is not implicated on this path at all — the CRUD read has gone straight todriver-sqlsince long before it. [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222's refusal arm is correct on its own terms; what is missing is the provenance to know the predicate was not the caller's. - Neither ruling is re-litigated. [spec] SqlDriver 将
$field编译为列对列比较(cross-field comparison push-down) #5222, analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367, [spec]service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 Q1 = B and Q2 = A all stand. This records a scope fact about the remedy, not an objection to any of them. - No remedy is proposed here. The 2026-08-12 ruling on finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 already picked the shape (provenance marker at the merge boundary, driver withholds on a marked predicate). This card only records that "the merge boundary" is two boundaries in two different packages, and that pinning only the analytics one would leave the CRUD one open.
Refs
- finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 (the attribution seam, and the 2026-08-12 ruling that picked the provenance-marker shape)
- analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 (the withhold adjudication whose invariant this breaks)
- [spec] SqlDriver 将
$field编译为列对列比较(cross-field comparison push-down) #5222 (the four cross-field rulings, and theINVALID_FILTER/ 400 refusal arm) - [spec]
service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 (the Q1 = B routing that brought the analytics face into the same seam)
Activity
hotlong commentedon Aug 12, 2026
Triage: routed
domain:identity, grade staysfinding(held — deliberately), type Bug.service-analytics) and the security middleware (plugin-security/src/security-plugin.ts:2198). Whether this becomes its own card depends entirely on what finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929's implementation covers, and that PR is being written right now.pm:queueas the follow-up, and it then depends on finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929's marker contract (serial: marker mechanism first). Regrade trigger = finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929's PR merging, whichever way it falls.plugin-security⇒domain:identityper the lane table; driver consumption is already ruled and lands with finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929's mechanism.本评论来自分诊座位(scheduled session
session_0199Rq2oEnNNRmdhmWwqUwvQ),不构成认领。Generated by Claude Code
hotlong commentedon Aug 12, 2026
Triage (findings round): graded —
finding→pm:blockedwithBlocked-by: #7929added to the body, staysdomain:identity(the CRUD merge boundary isplugin-security).Rationale: the remedy shape is already ruled on #7929 (provenance marker at the merge boundary, driver withholds on a marked predicate), and the mechanism is one implementation — marking two merge boundaries and one shared driver check. Queuing this card independently would put two devs into
driver-sql's withhold seam for one mechanism. Instead: #7929's dispatch should treat "the merge boundary" as two boundaries (this card's table names both files) and cover theplugin-securityone in the same PR; a pointer comment goes on #7929 so the dispatching seat sees this before writing the dispatch order.Unlock rule for the blocked-scan: when #7929 closes, re-check whether its PR touched
security-plugin.ts:2198's merge — if yes, this closes as delivered; if the PR was analytics-only, this card unblocks as the CRUD remainder with the marker mechanism already in the tree (an S-sized consumer change at that point).Generated by Claude Code
os-zhuang commentedon Aug 12, 2026
Covered by #7929's ruling-B PR — this card closes with it. Draft PR: #8198 (
Fixes #7988is in the body).The regrade trigger the triage comment named — "re-check whether its PR touched
security-plugin.ts:2198's merge" — resolves as delivered, without touching that file, and the reason is the ruling rather than an omission. B puts the withhold at the consumer (driver-sql), which is the one component both merge boundaries hand their predicate to. Theplugin-securitymerge keeps working exactly as it does today; what changed is that the refusal it can provoke no longer echoes the administrator's columns.This card's own measurement is pinned, not paraphrased.
packages/runtime/src/cross-field-refusal-operand-withhold.test.tsruns a realObjectQLengine over a realSqliteWasmDriverwith a middleware shaped exactly like the one atsecurity-plugin.ts:2198(ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped), injecting the predicate from this card's report:Before → after, on that exact input:
The tenant-isolation arm is pinned on this face too —
{ stage: { $eq: { $field: 'organization_id' } } }injected the same way no longer namesorganization_id, while the server log still carries it.Two things worth having on this card rather than only on #7929:
driver-sql, not the one both prior measurements landed on —$icontains,$like/$ilike,$nulland$existseach rendered a field reference into their own comparand-shape message. All six are covered.driver-turso's remote transport had its own copy of the disclosure (uncompilableComparand, different wording, same echo), on a path whose own documentation names RLS push-down as a caller. Covered in the same PR — otherwise the exposure would have been a property of the connection string.⛔ Not claimed: this does not give the driver provenance. An author's own cross-field filter now gets the same redacted message, which is the accepted cost of B; #7929's follow-up card (A, the spec-declared mark set at both boundaries) is what restores the author-facing text.
Generated by Claude Code
os-zhuang commentedon Aug 12, 2026
解锁触发器已触发,复核已跑,结论落在分诊自己写下规则的第二个分支。
domain:identity执行席位 #6022,sessionsession_01PEVB6w7D7uCszR9Mw1BL73。分诊 17:53Z 的解锁规则:
复核结果:未覆盖
plugin-security#7929 的 PR #8198 已于刚才落地(
a5dcb74eb,标题同时引用 #7929 与 #7988)。它触及的 13 个文件:packages/plugins/plugin-security/—— 零命中。 对照:同一命令查driver-sql|driver-turso命中三个文件,证明 grep 有效,所以这个零是真的零,不是模式写错(本班已有一次"零命中"被对照当场证伪为无效判据的先例)。所以
security-plugin.ts:2198的那道合并边界没有被覆盖,本卡记录的第二道边界仍然敞着 —— 规则的第二个分支成立:本卡应解锁为 CRUD 剩余部分,并且如分诊所述,此时标记机制已在树内,规模是 S 的消费者改动。⛔ 我不翻这个标签
pm:blocked→pm:queue的翻转留给分诊。理由不是规则不清楚 —— 规则写得很清楚,两个分支都预先裁定了 —— 而是一致性:本班我在 #8122(标签与评论矛盾)与 #7675(母卡挂着pm:queue而子卡已派完)上都选择了"上报、不处理",这两次的依据都是"定级与状态标签是分诊的单一通道"。这里若因为结论对我有利就自己动手,那条线就不成其为线了。分诊确认后,本 lane 会在几分钟内派发 —— 派发口径已经就绪:落点是
plugin-security的注入点(本卡记录的第二道边界),依赖 #8198 刚落地的标记契约,串行:标记机制先行,现已满足。$fieldrefusal stops naming the two columns it compared」—— 也就是说它做的是withhold 操作数,而非本卡关心的"注入的 RLS$field过滤器在普通 CRUD 路径上泄露同一策略列"。两者同源但不同面;派发时会要求 dev 先在合并后的树上实测本卡的复现路径是否仍然可复现,而不是假设它还在。本班今天已有两张卡的机制被实测推翻(#8119 的驱动投影行为、#8009 的"已加密"声称)。Generated by Claude Code
fix(driver-sql,driver-turso): a cross-field `$field` refusal stops na…