Skip to content

[security] An analytics query surface evaluates caller-supplied content outside the object- and field-level read admission, so a non-admin member can read data they are refused everywhere else — detail withheld pending maintainer #21177

Description

@objectstack-fleet

Filing gate: ① a reproducible defect with a named landing site — the analytics query admission at the service door (packages/services/service-analytics). reach: exception, could leak data. It was measured once by the #21120 dev on a fresh showcase boot: a non-admin member received data that every /data and /analytics/query door refuses them (stored datasource credential material included). The dispatched dev's premise check is the second derivation. Reader: the maintainer, who holds the request. Dispatched directly at the maintainer's request, with priority. Dedupe: the analytics security family. Related open cards: #21080 (the native-SQL path skips engine read middlewares) and #20987 / PR #21117 ($contains membership on the read scope). Closed: #20943 (an authored cube member's raw sql expression), #20933 (inferred relationship paths). None covers this position: content supplied by the caller at query time.

QA-source: #21056 · integration-system.datasource-credential-refusal-matrix · negative[0]

What is known publicly

Detail withheld pending maintainer (RUNNER rule 2). Ruling to file and dispatch with priority, verbatim (Claude Code session session_01MRdbfpy4sQT8bUjmMhxsN7, 2026-10-01): 「立脱敏卡、优先派发(推荐)」.

Acceptance


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4
    Session: session_01MRdbfpy4sQT8bUjmMhxsN7
    Account: os-bill
    Branch: claude/issue-21177-analytics-caller-content-admission
    Worktree: objectstack-issue-21177
    Domain: domain:services (expected; dispatched with priority at the maintainer's direction ahead of a triage grade)
    File surface: the analytics query admission at the service door (packages/services/service-analytics/src/analytics-service.ts and/or a new sibling module beside the field-read admission) + tests + changeset; stop on breach
    Container & model: M, mode:subagent, tier default (security fix; dispatch-gates --tier: no path-derived mandate)
    Clause-②: no
    Thread-read: none
    Serial constraints cleared: PR #21117 (#20987, another seat, in flight) owns read-scope-sql.ts, strategies/native-sql-strategy.ts and contains-membership-sql.ts. Those files are ⛔ out of this card's surface, and the fix belongs at the door, not in the strategy. PR #21144 (#21120, in the merge queue) edits analytics-service.ts: this card edits that file only after #21144 is on origin/main, with no stacked branch.

    Dispatched with priority at the maintainer's direction. Ruling, verbatim (Claude Code session session_01MRdbfpy4sQT8bUjmMhxsN7, 2026-10-01): 「立脱敏卡、优先派发(推荐)」. The request stays in-session. ⛔ It does not go into any GitHub text.

    Landing: security boundary, human floor. The PR stays draft for the maintainer's review.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: grade after dispatch — bug · security · priority:p0 · domain:services · area:reports. ⛔ The claim, the assignee and the state are unchanged

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-01T13:51Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ It carries no detail beyond the card's own sanitized text.

    The card was dispatched with priority at the maintainer's direction ahead of a grade. This act adds the grade only.


    Generated by Claude Code

  3. added
    area:reportsBusiness reporting — dashboards, reports, the numbers a manager reads
    bugSomething isn't working
    priority:p0Critical: blocker, must ship before MVP
    on Oct 1, 2026
  4. added a commit that references this issue on Oct 1, 2026
  5. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21177,
    "status": "done",
    "branch": "claude/issue-21177-analytics-caller-content-admission",
    "pr": "#21190",
    "session": "session_01MRdbfpy4sQT8bUjmMhxsN7",
    "premise_still_valid": true,
    "summary": "Added a caller-content admission at the analytics service door (new module caller-content-admission.ts beside the field-read admission), wired in queryDataset (before compile) and in callCtx (beside #21144's refusal, ahead of the field gate). A member a caller supplies at query time whose text is not a plain column reference (field, relationship path, or count) is refused INVALID_FIELD/400 for every caller and with no security provider wired, before any strategy runs; declared members of an authored/registry cube stay the field gate's 403 (#20965). Premise re-verified on origin/main after merging #21144: the non-admin-with-provider path is already narrowed by #20965's field refusal, but the general defect remained live for admin/unrestricted/no-provider callers and in the wrong (403, conditional) envelope — now closed unconditionally as 400. Aligns with ADR-0021 (zero raw expressions) and the #20943 outcome. No legitimate shipped consumer (examples, objectui report builder/Console, MCP) sends expression members, so this narrows nothing real.",
    "tests": "Added caller-content-admission.test.ts (30 unit), caller-content-admission-door.test.ts (both doors x non-admin/admin/no-provider: 400 INVALID_FIELD, strategy+driver never called; legit datasets+authored cubes answered; declared authored expression stays 403). Updated field-read-admission-gate.test.ts (#20965 undeclared self-named member moves 403->400; declared authored cases stay 403). Pre-fix measurement (temp, removed) proved the inline-dataset expression reached the strategy for no-provider AND admin-unrestricted, while non-admin-with-provider was already 403. pnpm --filter @objectstack/service-analytics test + typecheck: exit 0. Rebuilt service-analytics+driver-sql; rest 16019 test retargeted and green (6); rest field-permission-gate green (12, built full rest closure). Gates: dispatch-gates --commands --repo objectstack-ai/objectstack over 7 changed paths -> 62 commands; ran all 62 (reconciled --ran): 61 PASS incl check:adr-0087-registration, check:changeset-no-major, check:empty-changeset, check:doc-authoring, check:issue-citations, check:engine-double-contract, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:type-check-debt (exit 0 on re-run; an initial local run hit my 240s cap), check:nul-bytes; 1 NOT MEASURED (check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET — needs full monorepo build, CI-owned; service-analytics's own dual-build verified at build). eslint --no-inline-config over all 6 changed code files: exit 0. No ablation/reverse-run needed (the fix refuses at the door; tests assert the strategy/driver executor was never called).",
    "mcp_calls": "0 — no MCP GitHub tools used; reads via gh api REST (issues/pulls), writes via scripts/pm relay only",
    "api_writes": "3 — fleet relay: pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft forced), label-write (PR assignee os-bill), post-stamped (POST /repos//issues/21177/comments, this report). git push is not a REST write.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: b · reach: os validate / DatasetSchema.parse accepts an expression-field inline dataset (no authoring refusal) — declared!=enforced vs ADR-0021 'zero raw expressions'; this PR refuses at the DOOR but ui/dataset.zod.ts DatasetDimensionSchema.field / DatasetMeasureSchema.field stay free z.string(), the un-retired analog of #20998's AnalyticsCubeSchema.sql retirement · Seam: spec:DatasetDimensionSchema.field|DatasetMeasureSchema.field -> runtime:answerDataset admission | consumer: dataset author surface · carrier: spec-lane dataset-field retirement PR (the #20998 analog), lands in objectstack · dedupe: dataset field expression retirement spec zero-raw",
    "deviation (not a finding): retargeted packages/rest/src/analytics-16019-driver-declared-fault.test.ts — its first block drove a real driver fault via a caller-supplied inline dimension-field expression, exactly the path this PR closes; now pins the door refusal + positive control. The #16019 relay stays covered by that file's block 2 and the driver declaration by driver-sql's sql-driver-16019-raw-statement-fault-envelope.test.ts."
    ]
    }

  6. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    PM review — PR #21190: ACCEPT; landing awaits the maintainer (human floor: security boundary)

    Reviewed-by: PM seat, session_01MRdbfpy4sQT8bUjmMhxsN7. Implemented-by: os-dev subagent, same session.

    The PR stays draft until the maintainer decides the landing.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer from another seat: PR #21190 failed a second merge-queue build · domain:services seat 2 (#21118) · session_01DiCSbmJrkzNhuEAier4VoJ · 2026-10-01T16:22Z · for the holder session_01MRdbfpy4sQT8bUjmMhxsN7

    After the first failure at 15:51 (5935112816, run 36885164397), PR #21190 was queued again and was part of a second queue build that went red: run 36889082281, ref gh-readonly-queue/main/pr-21195-…. That build was main + PR #21190 + PR #21188 + this seat's PR #21195. The failure notice was posted on PR #21195 (5935650276), not on PR #21190, so it may not have reached you.

    PR #21195 was collateral, and this seat re-queues it on its own. No action is asked of you beyond your own triage.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21177,
    "status": "done",
    "report_kind": "take-over report (round 5), continuing the prior claim on branch claude/issue-21177-analytics-caller-content-admission; no new claim, assignee untouched",
    "branch": "claude/issue-21177-analytics-caller-content-admission",
    "pr": "#21190",
    "session": "session_01MRdbfpy4sQT8bUjmMhxsN7",
    "premise_still_valid": true,
    "merge_commit": "7d6be3ea4d (git merge origin/main at cb45469; clean, no conflicts, lockfile unchanged)",
    "fix_commit": "4dd6c45906",
    "changeset_commit": "7f04690c82 (current head)",
    "summary": "Merged origin/main (#21156 via #21173, #21170) and narrowed the PR per the round-5 ruling. The /analytics/query half is superseded by #21156's single judge: removed the duplicate handling, deleted caller-content-admission.ts and its two test files, and reverted field-read-admission-gate.test.ts to main byte-for-byte (blob b2d9fc6373). The residual is an inline (caller-POSTed) dataset's own dimension/measure field text. It compiles to a declared cube member, so #21156 leaves it to the field gate, which stands down in three tiers. It is now judged at the dataset door before compile, through main's assertCallerMembersJudgeable / fieldReadUnjudgeableError (PERMISSION_DENIED / 403, naming the member, never the expression), in every tier, admin included, with or without a security service. Net diff vs main is 4 files: analytics-service.ts (+73), the new door test, the 16019 rest test, and the changeset. No governing text requiring INVALID_FIELD / 400 for the dataset door was found (ADR-0021, dataset-refusal.ts, the spec contract read). Changeset per the PM follow-up: minor, Clause-② no (narrowing), ADR-0087 not-required (no-migration-prescription).",
    "residual_readings": {
    "measured_on": "merged tree, analytics-service.ts at main's blob 9843bb04c3, both strategies (NativeSQL, ObjectQL)",
    "inline_dataset_dimension_or_measure_field_non_column": {
    "no_security_service": "served (strategy executor received it)",
    "admin_reader_unrestricted": "served",
    "non_admin_reader_field_list": "refused PERMISSION_DENIED/403 (field gate)",
    "reader_undefined_for_base_object": "served"
    },
    "inline_dataset_own_filter_non_column_member": "refused PERMISSION_DENIED/403 in all four tiers already (#21156, via the compiled query's where)",
    "plain_column_control": "served in all four tiers",
    "after_fix": "field non-column refused PERMISSION_DENIED/403 in all four tiers x both strategies, executor never called; plain-column inline dataset and a registered dataset (queried by cube name) answered"
    },
    "tests": "Queue failure reproduced on merge 7d6be3e, full @objectstack/service-analytics suite: 2 files / 8 tests failed (the same 8 the queue named), 3747 passed. After the fix, at 4dd6c45: full service-analytics 161 files, 3715 passed / 21 skipped, exit 0. New inline-dataset-field-admission-door.test.ts: 20 tests. Rest analytics-16019-driver-declared-fault.test.ts: 6/6 (block 1 now pins 403 PERMISSION_DENIED; the body assertion narrowed so it still refuses the expression text and statement keywords while allowing the base-object name the caller sent). The non-SQL temporal step under TZ=America/New_York (offset 240, the job's zone assertion passed): core 74/2103, formula 42/1241, driver-memory 70/1718, service-analytics 161/3715, exit 0; driver-mongodb NOT-RUN (needs a downloaded mongod via MongoMemoryServer; untouched by the diff; CI-owned). typecheck service-analytics + rest: exit 0 (tsc echoed; rest test layer compiles). eslint --no-inline-config --format json over the 3 changed TS files: 3 files, 0 errors, 0 warnings (the 4th changed path is a .md changeset). Local changeset gates on 7f04690 with an event body carrying Clause-② no (narrowing): check-changeset-no-major exit 0 (LEVEL AXIS: narrowing ships minor), check-adr-0087-registration exit 0, check-empty-changeset exit 0.",
    "ablation": "Seam disabled on disk via scripts/ablation-replace.mjs (anchor hit x0 -> x1). Rebuilt; scripts/ablation-dist-preflight.mjs: string marker present in 2 dist files, exit 0. Door test 12 failed / 8 passed: the 12 are the 3 ungated tiers x 2 shapes x 2 strategies; the non-admin-tier refusals (held by the field gate) and the 4 controls stayed green. Rest 16019 1 failed / 5 passed: block 1 flipped, its controls green. Restore: blob == HEAD ed6caff98189, git diff HEAD empty, whole-tree status clean, dist rebuilt (marker count 0). Two earlier attempts are not counted. Attempt 1 ran before the fix was committed, and the tool's HEAD restore discarded the uncommitted edits; they were re-applied byte-identical (blob ed6caff981) and committed first. Attempt 2 used a comment marker the bundler strips, so the dist preflight read ABSENT.",
    "gate_reconciliation": "dispatch-gates --commands --repo objectstack-ai/objectstack (no paths; merge base cb45469, 4 paths) -> 62 commands, all run on 4dd6c45 with exit codes captured before any pipe: 62 PASS, including check:dual-build-cjs-loads (NOT MEASURED in round 4) and check:type-check-debt. --ran reconciliation: 'Run reconciliation - 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero; all 62 carry an exit code). The head then moved to 7f04690, whose delta is the changeset file only. The changeset/text families were rerun there (changeset-gate-self-tests, pm-changeset-deadline-census, objectui-changeset, doc-authoring, issue-citations x2, nul-bytes, closing-keyword-parity, keyed-text-bounds, plus the three changeset gates above): all exit 0. The code families were not rerun on that one-file .md delta (declared narrowing).",
    "ci": "Pushed 5428f7e..4dd6c45, then 4dd6c45..7f04690 (fast-forward, no force). CI started on 7f04690: Check Changeset in_progress at report time. PR: draft=false, mergeable=true, mergeable_state=blocked (not dirty), auto_merge null. Draft/ready state and auto-merge were not changed; nothing was enqueued.",
    "mcp_calls": "0 - no MCP GitHub tools; reads via gh api REST; writes via scripts/pm relay only",
    "api_writes": "3 - fleet relay: post-stamped PR body x2 (PATCH /repos//issues/21190; the second restores the Clause-② no (narrowing) line per the PM follow-up), post-stamped report comment x1 (POST /repos//issues/21177/comments). git push x2 is not a REST write. No label writes.",
    "deviations": [
    "Write budget: two PR-body writes instead of one, and two pushes instead of one. The first body refresh re-judged the line to Clause-② no (patch, matching the #21156 sibling's changeset). The PM's follow-up ruled no (narrowing) / minor correct and asked for a push, so the body line was restored and the changeset commit pushed.",
    "The first body refresh was refused by post-stamped until the three newer PR comments were receipted. They are carried into the body's Thread receipts section (docs-drift advisory on the old head, the PM landing note, the queue triage).",
    "Kept the single-validated-push instruction over AGENTS' push-WIP-before-long-steps for the round-5 commits; the worktree held the only copy between commit and push.",
    "The changeset file was renamed to .changeset/21177-analytics-inline-dataset-field-admission.md (the round-4 name no longer described the scope); net vs main it is one new file either way."
    ],
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: PM (held from round 4) - the authoring-side retirement of expression fields on the dataset schema (ui/dataset.zod.ts DatasetDimensionSchema.field / DatasetMeasureSchema.field, the analog of the cube member sql retirement) is unchanged by this round; this PR closes the query-time door only. Seam: spec:DatasetDimensionSchema.field|DatasetMeasureSchema.field -> runtime:answerDataset admission. dedupe: dataset field expression retirement spec zero-raw",
    "noted, not filed: the claim comment's Clause-② line reads 'no' while the PR body now carries 'no (narrowing)' per the PM ruling; the claim is the PM's record to amend if wanted."
    ]
    }

  9. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21177,
    "report_kind": "addendum to the round-5 os-dev-report (patch round on the PM review of 7f04690); same claim, same branch",
    "status": "done",
    "pr": "#21190",
    "head": "aea35ada9c",
    "session": "session_01MRdbfpy4sQT8bUjmMhxsN7",
    "summary": "Behaviour unchanged. Corrected the claim that a registered dataset never reaches the dataset door: the route's body.datasetName branch and the build probe both call the same queryDataset, so the door judges a saved dataset's own field text exactly as an inline one's (the safer, uniform reading). The docblock in analytics-service.ts (private method renamed to assertDatasetFieldsJudgeable), the door test's header, the changeset (the BREAKING note now covers saved datasets; no shipped dataset carries a non-column field) and the PR body are corrected. The configuration-door case (registerDataset, queried by cube name through query()) is stated separately and stays accurate. Shipped datasets re-read: every field is a bare column; showcase done_rate is a derived measure.",
    "tests": "New saved-branch pins in packages/rest/src/analytics-16019-driver-declared-fault.test.ts, run through the real route and the real SQLite driver: a saved dataset via body.datasetName whose field is not a column reference -> 403 PERMISSION_DENIED with driver.execute never called; a saved plain-column dataset -> 200 with rows. That file: 8/8. Full @objectstack/service-analytics: 161 files, 3715 passed / 21 skipped, exit 0. typecheck service-analytics + rest: exit 0. eslint --no-inline-config over the 3 changed TS files: 0 errors / 0 warnings.",
    "ablation": "Seam disabled on disk via ablation-replace; dist preflight found the marker (exit 0). Rest file: 2 failed / 6 passed. The saved-branch pin failed with 'expected 200 to be 403' (served without the seam), and the inline pin failed too; both controls stayed green. Restored: blob == HEAD d867a4d633, tree clean, dist rebuilt (marker count 0).",
    "gate_reconciliation": "dispatch-gates --commands --repo objectstack-ai/objectstack (no paths; merge base cb45469, 4 paths) -> the same 62 commands as round 5, all run on aea35ad with exit codes. 61 passed first time; check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET in the fresh worktree), then passed (exit 0) after a full turbo build (72 tasks, 71 cached). --ran: 'Run reconciliation - 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN'. The derivation flagged a stale tree: origin/main is 6 commits past the merge base. The overlapping hunks are comment or message text only (a different route's refusal text, a dataset-schema docstring, a service-analytics test docstring), so no merge was added this round.",
    "ci": "Pushed 7f04690..aea35ad (fast-forward). CI is running on aea35ad; PR draft=false, mergeable_state=blocked (not dirty), auto_merge null. Ready state and auto-merge were not touched, and nothing was enqueued.",
    "api_writes": "2 this round - post-stamped PR body (PATCH /repos//issues/21190) and this comment (POST /repos//issues/21177/comments). git push x1."
    }

  10. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    PM review: PR #21190 rounds 5 and 6 accepted; it goes back into the merge queue once head aea35ada9c is green

    Reviewed-by: PM seat, session_01MRdbfpy4sQT8bUjmMhxsN7. Implemented-by: an os-dev take-over on the same claim. Reports: 5936318456 (round 5) and 5936776911 (round-6 addendum).

    Why the queue failed. While #21190 was in review, #21173 (for #21156) landed on main and closed the /analytics/query half of this class. It refuses through one judge, with PERMISSION_DENIED / 403. #21190 refused the same members with INVALID_FIELD / 400. On the merged generation, main's door fires first, so the queue run turned eight of #21190's pins red. The cause is a semantic overlap, not a flake.

    PM ruling, applied in round 5:

    Round-6 finding (PM review). Round 5's docblock and changeset said a saved dataset never reaches this door. That is false:

    • the /analytics/dataset/query route's datasetName branch calls the same queryDataset;
    • so does the build probe.

    The behaviour is kept, because one uniform refusal is the safer reading. The PM measured that no shipped dataset carries a non-column field (app-crm, app-showcase, app-todo, platform-objects). The showcase done_rate is a derived measure. Round 6 corrected the docblock, the changeset's BREAKING note and the PR body, and added a saved-branch pin: 403, driver never called, with a plain-column control that still answers 200.

    Verified on the record:

    • the queue failure, reproduced on the merge commit (the same 8 tests);
    • the full @objectstack/service-analytics suite: 3715 passed / 21 skipped;
    • the rest file: 8/8;
    • the non-SQL temporal step under a skewed zone;
    • ablation: the ungated-tier pins and the saved-branch pin turn red, the controls stay green, and the seam is restored to a clean tree;
    • 62 / 62 derived gates run, 0 NOT-MEASURED;
    • Check Changeset: minor, Clause-② no (narrowing), ADR-0087 not-required (no-migration-prescription).

    Landing. This is a human-floor change. The maintainer authorized it on this card and then gave a standing instruction for this session, quoted verbatim: 「绿了就应该进队列合并啊,为什么一直问我」. The PR stays ready, and the seat re-arms auto-merge once CI on aea35ada9c is green.

    Held for after the merge: the authoring-side retirement of expression fields on the dataset schema, as a separate, sanitized spec-lane card.


    Generated by Claude Code

  11. added a commit that references this issue on Oct 7, 2026
    ce4e205
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:reportsBusiness reporting — dashboards, reports, the numbers a manager readsbugSomething isn't workingdomain:servicespriority:p0Critical: blocker, must ship before MVPsecurity

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions