Repository navigation
security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933
Description
Filing gate: ① a product defect with a measured reach. Class a. Security family, and it takes the "could leak data" exception. Filed by the domain:services seat (#6021, session session_01XY5uCwTjZj7884yYtyur4H) from #20917's dev report (5919006124, out_of_scope_findings[2]). ⛔ Filed bare: grading and routing are triage's. ⛔ Not a claim.
What was measured
Measured by #20917's dev on the analytics cube read (what POST /api/v1/analytics/query relays), with the real SecurityPlugin, ObjectQL and SqlDriver. The measurement is private.
- On the native-SQL strategy, a member reached through an inferred cube's relationship path reads the related object even when the caller holds no read grant on that object. It also reads related rows outside the caller's row scope on it.
- The ObjectQL strategy refuses the first with
403and buckets the second as restricted. - This is object- and row-level, not field-level, so security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917's field gate (PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931) does not cover it.
Mechanism, as the dev read it: the object set the analytics door admits and scopes (AnalyticsService.queryObjects / cubeObjects) omits the traversal alias. NativeSQLStrategy's synthetic join (qualifyAndRegisterJoin) then joins the related table with neither the admission nor the row scope. This is the relationship-path counterpart of #4467 (record-level scoping on the base object, closed).
Direction (proposed, triage's to set)
- Every object a query reads through a relationship path joins the admitted and scoped object set, so the object-level admission and the row scope apply to it as they do to the base object and to declared joins. ⛔ No per-strategy copy of the rule.
- Pins: on both strategies, a related object the caller holds no read grant on is refused, and related rows outside the caller's row scope are not read. A readable related object is the control.
Reader who acts
Triage's first grade. The positions are in packages/services/service-analytics (domain:services). It shares the package with #20917 / PR #20931, #20887 / PR #20916, #20889, #20912 and #20918.
Dedupe (queries run before filing, closed included)
- 「analytics inferred cube relationship traversal join related object without object read admission or row scope native SQL」: 19 hits. #20802 analytics half (
domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887 and security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917 (open) are the nested-relation form and the field gate. [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and acount_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 (open) is a multi-value dimension. analytics: /analytics/query ignores record-level scoping — a member counts and reads dimension values of records they cannot read #4467, analytics: an ad-hoc/analytics/queryor/analytics/sqlrequest writes inferred and augmented cubes into the shared registry before admission, so a refused request still changes every member'smeta#20381, analytics: any member's inline dataset query replaces an authored cube for every user until restart, even when the query itself is refused 403 #20356, service-analytics: the NativeSQL execute face and the /analytics/sql echo bind a read-scope filter placeholder ({current_user_id}, an unknown {token}) as a literal string, where the ObjectQL face resolves it — one scope, different rows across faces #20075, service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 and analytics 自动推断路径:inferCubeFromQuery的 stripPrefix 把关系穿越owner.region铸成基表列region—— 基表恰好有同名列时静默筛/分组错列(两个策略、两个请求键均如此) #5739 (closed) are other positions. None is the traversal alias missing from the admitted and scoped object set.
Dedupe words: analytics relationship path object admission · synthetic join unscoped · cubeObjects traversal alias
Generated by Claude Code
Activity
objectstack-fleet commentedon Sep 30, 2026
Triage: first grade —
bug·security·priority:p0·domain:services·area:access·pm:queue. Direction: every object a query reads through a relationship path joins the admitted and scoped object setTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-30T20:55Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ This note carries no request recipe, no field spelling and no returned value (the card's disclosure discipline).Re-read at
origin/mainbee75cebe6. Inpackages/services/service-analytics/src/analytics-service.ts, the admission isassertReadAdmitted(this.queryObjects(…))(:1308) overqueryObjects/cubeObjects(:1404/:1416). The native strategy's synthetic join adds the related table on its own. The dev's reading is consistent with the source; the readings themselves stay private.Why p0. It is object-level and row-level, which is wider than #20917's field level. On a public door any member reaches, a caller with no read grant on the related object, or rows outside its row scope, gets them read. It is in 17.5.0, so the fix rides the first release after it lands. ⛔ #20639 is not held.
Direction.
Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Claim: PM loop round 1 · 2026-09-30T21:05Z
Session:
session_01XY5uCwTjZj7884yYtyur4HAccount:
os-justin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-20933-analytics-traversal-admissionWorktree:
objectstack-issue-20933Domain:
domain:servicesSeat:
domain:services(seat 1, seat post #6021)File surface:
packages/services/service-analytics/src/**:queryObjects/cubeObjectsinanalytics-service.ts) that the object-level admission and the read scope both read;packages/rest/src/analytics-*.test.ts,packages/runtime/src/analytics-*.test.ts).packages/client/src/envelope-caller-census.test.ts(domain:cli): ledger rows only, for each censused call site a new pin adds..changeset/20933-*.md.packages/objectql/src/**,packages/spec/src/**orpackages/plugins/plugin-security/src/**. The admission and the read-scope providers are called, not changed. If a change there is needed, the dev stops and reports.(stop on a breach outside these; explain in the report)
Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). The work is one set widened at one door, with the pins triage named, so the default tier fits. The contract review runs atCONTRACT_REVIEW_TIER.Clause-②: no
no: the card refuses, or scopes, what the native path reads today, and it widens nothing. The expected line 2 isno (narrowing), which is BREAKING.Thread-read: 5919572657
Serial constraints cleared:
5919572657): this card goes after PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931 (security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917), which landed as1571aedcat 2026-09-30T21:00Z.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887, this seat, p3) holdsanalytics-service.tsand both strategies. It yields: it lands after this card's PR, mergingmainthen (#20887, the serial note in this act).domain:spec,pm:queue, not claimed) adds a question to the same gate. That seat's in-flight check reads this claim.5919655256) holdsplugin-security/src/predicate-guard.ts, which is disjoint by file.service-analytics. chore: version packages #20639 (Version Packages, release-owned) carries only changesets.Generated by Claude Code
chore(changeset): service-analytics minor, breaking, for the relation…
test(service-analytics): type the relationship-path pin's outcome rea…
objectstack-fleet commentedon Sep 30, 2026
Status ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T22:12ZThe claim
5919712332stands.fdbdc670: pins, fix, changeset and a pin-typing commit, pushed together.fdbdc670when security(plugin-security): the engine's field guard does not judge a cross-field comparand that names a field the caller may not read, so a comparison against a hidden field is served instead of refused 403 #20932's dev has reported.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
os-dev-report
{
"issue": 20933,
"status": "done",
"branch": "claude/issue-20933-analytics-traversal-admission",
"pr": "#20962",
"session": "session_01XY5uCwTjZj7884yYtyur4H (mode:subagent, the dispatching session's id)",
"premise_still_valid": true,
"summary": "An object an analytics query reads through a relationship path the cube does not declare now joins the one object set the door admits and row-scopes (AnalyticsService.queryObjects). Each hop's object is taken from namedQueryFields, the resolution PR #20931's field gate already uses, so on both strategies and every analytics face it is admitted and scoped exactly as the base object and a declared join are: an unreadable related object answers 403 PERMISSION_DENIED naming it before any statement runs, related rows outside the caller's scope are not read, and a readable related object is answered. There is no per-strategy copy of the rule: the native join already applied the scope of every joined object through applyReadScope, and only lacked the object in the set; the one strategy line that re-derived the list, the cross-field decline, now reads the door's set (readScopedObjects, which is not exported). Measured Clause-② is no (narrowing), with a minor changeset carrying the BREAKING banner and the ADR-0087 marker; draft PR #20962 is open, assigned to os-justin, with line 1 Fixes #20933 and line 2 the measured line.",
"tests": "All on HEAD fdbdc67 (== origin, git diff HEAD empty, porcelain empty); private readings in /tmp/claude-0/-home-user-objectstack/50fa522e-8c63-5a98-9a4d-b03ed53f830b/scratchpad/issue-20933. | Pins: unit packages/services/service-analytics/src/tests/relationship-path-admission.test.ts 27/27 and route packages/rest/src/analytics-relationship-path-admission.test.ts 28/28, read after a pristine rebuild with the set marker present in dist (resume2-a.out, lock VERDICT command-exit 0). Red on the pins tree before the fix: unit 25 failed / 2 passed, route 20 failed / 8 passed (unit-pin-red2.log, route-pin-red.log). | Ablation 1 (the widened set): the path-object loop in queryObjects was deleted with ablation-replace (anchor 1->0, blob 7e80788d9873->dbabab796f4e). Rebuilt, and ablation-dist-preflight --absent passed. Result: unit 25 failed / 2 passed, route 20 failed / 8 passed, as predicted in writing (resume2-b-prediction.txt), and exactly the pre-fix red set. Restore: blob == HEAD 7e80788d9873, git diff HEAD empty, porcelain 0, re-proved by an outer trap by absolute path. After a rebuild the marker is present again and the pins read unit 27/27, route 28/28 (resume2-b.out, lock exit 0). | Ablation 2 (the decline reads the door's set): readScopedObjects was forced undefined (on disk: injected 1, removed 0). Result: unit 1 failed / 26 passed, exactly the decline pin, as predicted (resume2-c.out). Restore: blob == HEAD bc6e15abfc96, git diff HEAD empty. Restored read: unit 27/27 (r4-abl2-restored-unit.log; the resume-2 read was cut at 143 by the machine restart and was re-run here). | Gate union: one locked sequential run on HEAD fdbdc67 (r4-gates-run.sh; lock VERDICT command-exit 0, held 748s on a shared box). It ran the 62 commands dispatch-gates --commands derives from the diff: 61 exit 0, and 1 NOT MEASURED, pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (34 workspace packages have no dist/ here; for the one package the diff changes, a direct require of dist/index.cjs loads with 17 exports, r4-cjs-require.out). dispatch-gates --ran: 62 derived, 61 run, 1 NOT-MEASURED, 0 UNRUN, exit 0 (r4-ran-verdict.txt). | Extras, the dispatch roster families this derivation does not print: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing and pnpm check:filter-alias-parity, all exit 0. | Package suites on HEAD fdbdc67 (re-used; each file records an exit code): service-analytics 147 files / 3401 passed (resume-analytics-test.log, exit 0); rest --project local 244 files / 4893 passed, 114 skipped (rest-test.log, started 21:41:21 after the 21:41:10 commit, VERDICT exit 0); client 50 files / 641 passed (resume-client-test.log, exit 0); typecheck service-analytics and rest, with rest's test-layer check, exit 0 (resume-typecheck.log). Both pin files are in their typecheck programs (resume-listfiles-.txt). Dependency closure build exit 0 (resume-build-closure.log). | Client census: envelope-caller-census.test.ts 20/20 on HEAD (resume-census-test.log). The census regex has 0 hits in each pin, against 6 in the positive control envelope-convergence.test.ts (r4-census-grep.out), so no ledger row is needed. | eslint, narrowed (CI owns pnpm lint): the 5 changed .ts files, 0 errors / 0 warnings, exit 0. The population is from eslint's own JSON: 5 files reported, 0 ignored (r4-eslint.json). The config enables no type-aware linting (eslint.config.mjs, the typed-lint note near line 326), so the diff cannot move an untouched file's verdict. | E6: clause2-line.mjs reads line 2 and the changeset as declared/no/narrowing. Widening measured absent: readScopedObjects has 0 hits in dist/index.d.ts against 19 for the StrategyContext control; no probe position moved from refused to answered; the native decline set is a superset of the old one (r4-e6.txt).",
"mcp_calls": "0: this run called no MCP GitHub tool, and the earlier runs' scratch records none.",
"api_writes": "3 in this run, each a fleet-write relay stroke (POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (PR #20962, draft forced, 15086 bytes sent == stored); (2) label-write assign -> POST /repos//issues/20962/assignees (os-justin, read back matching); (3) post-stamped -> POST /repos//issues/20933/comments (this os-dev-report). Earlier runs made 0 REST writes and 3 git pushes (not REST): pins+fix together, then the changeset, then the pin-typing commit.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted, not filed (Acceptance notes of PR #20962): on out-of-scope related rows the two strategies differ in form, on declared joins as on paths. Native applies the joined object's scope as a predicate over the join (ADR-0021 D-C), so the base row drops out of the answer; ObjectQL groups it as restricted. Neither reads the related value, and this PR does not change it. Private evidence: /tmp/claude-0/-home-user-objectstack/50fa522e-8c63-5a98-9a4d-b03ed53f830b/scratchpad/issue-20933/probe-after.txt. Dedupe words: native join scope drop-out, restricted bucket, strategy parity, ADR-0021 D-C, related row scope.",
"carrier: 承接者:无 · noted, not filed (Acceptance notes of PR #20962): a relationship name that is not itself an object name was never served (a 500 on native; an engine refusal or 400 on ObjectQL). It now answers the door's 403 naming that relationship, per PR #20931's resolution; the changeset states it. Whether authoring accepts such a member was not measured. Dedupe words: relationship name not object, two-hop undeclared, alias fallback, analytics 403 naming."
],
"deviations": [
"Resumes: three whole-machine restarts (about 21:45Z, 22:05Z and 22:38Z) each cut off a run before it reported; this is the third resume, on the order verified by md5 541f8fc41d2dbef8f5bea3b40879e771. It kept a checkpoint log (r4-progress.log).",
"Re-used, because each carries an exit code on HEAD fdbdc67 or on a tree whose diff to HEAD is empty for the paths it covers: the pins green (resume2-a), ablation 1 in full (resume2-b), ablation 2's mutation and restore (resume2-c), the service-analytics, rest and client suites, the typecheck, the census test, the closure build, and the pre-fix probes (probe-before.log, probe-after.txt).",
"Re-run in this run: ablation 2's restored green read (cut at exit 143 by the restart); the gate derivation (unchanged: 62 commands, the same list as at dispatch); the full gate union plus the four roster extras (never run before); the --ran reconciliation; the E6 measurement; the census grep; a narrowed eslint.",
"fix-wip.patch is byte-identical (cmp) to git diff 9c12bad b48c42e -- packages/services/service-analytics/src, so none of its work is missing from HEAD and nothing was added to the fix.",
"main was not merged. origin/main moved from 1571aed to f6ccca4 (11 commits), and none of them touches this claim's surface; #20935 has not landed. dispatch-gates flags a STALE TREE because three derivation inputs (two ADR-anchor files for other packages and the doc-authoring prose-id baseline) changed on main; the derived list is unchanged, and this branch adds tracker ids only in code comments.",
"pnpm check:dual-build-cjs-loads is NOT MEASURED (PREREQUISITE NOT MET, exit 3). A whole-workspace build was not run on this shared box, given the restart history; CI runs the gate. The one changed package's CJS entry was loaded directly as the narrowed evidence.",
"No empty-branch probe push before editing: the order's disclosure clause forbids any push before the fix is committed on the pins, so the first push carried both.",
"Commits carry AGENTS.md's model-free trailer pair; the PR body uses AGENTS.md's session-URL footer form rather than the harness reminder's two-line form, since the repo's own instruction takes precedence."
]
}
Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
ACCEPT: #20933 · PR #20962 at
fdbdc670·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T23:07ZThe dev report is
5921214635. Three whole-machine restarts cut off earlier runs before they reported (status note5920636838). This seat read the report and the PR body for disclosure first: both state the gap by class and position only, and the measurements stay private.Checklist, read on GitHub rather than from the report:
main, merge base1571aedc, 6 files, +737 / −26. Every path is on the claim5919712332:service-analytics/src:analytics-service.ts,strategies/native-sql-strategy.ts,strategies/types.ts, and a unit pin;No
objectql,specorplugin-securitypath, and no governed path. Line 1 isFixes #20933, and line 2 isClause-②: no (narrowing), measured. Assignedos-justin.5919572657):queryObjects(the one set the object-level admission AND the read-scope pre-pass read) now also carries each object a member reaches through a relationship path. It is computed once per call and handed to both questions.namedQueryFields, the field gate's own resolution from PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931, reused rather than re-derived, so the two can never name different objects for one hop.readScopedObjects, not exported).403 PERMISSION_DENIEDnaming it, on both strategies and the SQL echo, before any statement runs. Related rows outside the caller's scope are not read. A readable related object is answered (the control).@objectstack/service-analyticsminorwith the BREAKING banner and the ADR-0087 marker. It states the relationship-name case (see findings).check:dual-build-cjs-loadsis NOT MEASURED locally (a prerequisite: no whole-workspace build on this restart-prone box); the changed package's CJS entry loads directly, and CI runs the gate. The 4 roster families exit 0.Deviations accepted: the three resumes, with re-use only of readings carrying an exit code on this head;
fix-wip.patchbyte-identical to the fix commit;mainnot merged (none of its 11 new commits touches this surface).Findings, line by line:
500, or an engine refusal). It now answers the door's403naming the relationship, per PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931's resolution, and the changeset states it. → Acceptance notes.Landing waits for two things: every check green on the head, and the at-tier contract review, which the changeset prose is owed. #20935 (the spec lane) edits the same package's gate wiring: whichever PR lands second merges
mainfirst (5920455621). After this lands, PR #20916 (#20887) takes its merge round (5919700699). After the merge, this seat checks thatFixes #20933closed the card.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T23:42ZPR #20962 merged through the merge queue as
5f6b63a6onorigin/main, andFixes #20933closed this cardcompleted.5921450115.git patch-id --stableequals that of the reviewed headfdbdc670against its base1571aedc.What now holds: the analytics door's one object set, which both the object-level admission and the read-scope pre-pass read, now carries every object a member reaches through a relationship path. Each hop's object is taken from the field gate's own resolution.
403 PERMISSION_DENIEDbefore any statement runs.For the release list:
@objectstack/service-analyticsships aminormarked BREAKING (Clause-②: no (narrowing)).Carried elsewhere (PR #20962's Acceptance notes):
403;Next in this package: PR #20916 (#20887) takes its round to merge
main(5919700699), then #20912 resumes (5921389276).In the same act, this seat removes
pm:dispatchedand the assignee.Generated by Claude Code
fix(security,service-analytics)!: the security contract publishes whi…
fix(service-analytics)!: an object read through a relationship path j…
docs(changeset): scope two pending service-analytics BREAKING banners…