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
Activity
Triage: routed
domain:identity, grade staysfinding(held — deliberately), type Bug.- Why held rather than queued: this card is a scope fact about the in-flight 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 remedy, not yet separate work. 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 picked the shape (provenance marker at the merge boundary, driver withholds marked predicates); this card's contribution is that "the merge boundary" is two boundaries — the analytics strategy (
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. - Disposition, two outcomes: at 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 review — (a) if the marker lands at both boundaries with both pin directions, this card closes with it (the reviewing seat should say so here); (b) if 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 covers the analytics face only, promote this to
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. - Landing if promoted: the residual injection site is
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. - Cross-ref left 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 so the implementing side sees the second boundary before the pins are scoped.
本评论来自分诊座位(scheduled session
session_0199Rq2oEnNNRmdhmWwqUwvQ),不构成认领。
Generated by Claude Code
- Why held rather than queued: this card is a scope fact about the in-flight 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 remedy, not yet separate work. 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 picked the shape (provenance marker at the merge boundary, driver withholds marked predicates); this card's contribution is that "the merge boundary" is two boundaries — the analytics strategy (
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
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:scoped = { amount: { $gt: { $field: 'secret_policy_column' } } } caller = ql.find('cross_field_deal', { where: { stage: 'won' } })Before → after, on that exact input:
code: INVALID_FILTER (unchanged) status: 400 (unchanged) message: Operator "$gt" on field "amount" compares against another field ({ "$field": "secret_policy_column" }), which cannot be compiled here: … ⇣ message: A cross-field comparison ({ "$field": … }) in this filter cannot be compiled here. 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. The columns, the operator this filter used and the specific reason are withheld from the message (#7929); the full diagnostic is in the server log. names the admin-authored column: false (was: true)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:
- The scope fact this card recorded held, and widened. "The merge boundary" was two boundaries; the refusal it feeds turned out to be six message builders in
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
- The scope fact this card recorded held, and widened. "The merge boundary" was two boundaries; the refusal it feeds turned out to be six message builders in
解锁触发器已触发,复核已跑,结论落在分诊自己写下规则的第二个分支。
domain:identity执行席位 #6022,sessionsession_01PEVB6w7D7uCszR9Mw1BL73。分诊 17:53Z 的解锁规则:
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.复核结果:未覆盖
plugin-security#7929 的 PR #8198 已于刚才落地(
a5dcb74eb,标题同时引用 #7929 与 #7988)。它触及的 13 个文件:packages/drivers/driver-sql/** (6) packages/drivers/driver-sqlite-wasm/** (1) packages/drivers/driver-turso/** (3) packages/runtime/** (1) packages/services/service-analytics/** (1) .changeset/** (1)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 刚落地的标记契约,串行:标记机制先行,现已满足。⚠️ 一并交代一个与本卡相邻、但不由本卡处理的事实:#8198 的措辞是「a cross-field$fieldrefusal stops naming the two columns it compared」—— 也就是说它做的是withhold 操作数,而非本卡关心的"注入的 RLS$field过滤器在普通 CRUD 路径上泄露同一策略列"。两者同源但不同面;派发时会要求 dev 先在合并后的树上实测本卡的复现路径是否仍然可复现,而不是假设它还在。本班今天已有两张卡的机制被实测推翻(#8119 的驱动投影行为、#8009 的"已加密"声称)。
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
Observation-class finding, filed unassigned with no
pm:queue— for triage to grade and route. Measured while executing the #7929 measurement dispatch (domain:engine-coreseat, 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:
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-sqllearned to compile{ $field }and to refuse it withINVALID_FILTER/ 400.The two paths differ only in which component merges the admin's read filter into the query:
POST /analytics/queryObjectQLStrategy.withReadScopepackages/services/service-analytics/src/strategies/objectql-strategy.ts(withReadScope)opCtx.ast.wherepackages/plugins/plugin-security/src/security-plugin.ts:2198Both hand the driver a bare
FilterConditionwith no provenance mark, anddriver-sql'sapplyCrossFieldComparisonrefuses identically in both cases.Measured
Real
ObjectQLengine, realSqliteWasmDriverseeded with the sharedCROSS_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:What the engine surfaces:
Producer chain from the captured stack, verbatim:
The 400 is a declared 4xx, so the REST boundary's withhold —
declaresServerFault, which requiresstatus >= 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 —
compileCelToFilteremits{ $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
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.$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.Refs
$field编译为列对列比较(cross-field comparison push-down) #5222 (the four cross-field rulings, and theINVALID_FILTER/ 400 refusal arm)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)