Skip to content

security(analytics): a field the caller may only see masked is answered unmasked as a grouped or filtered member on the native-SQL strategy; the published field reader has no masked-for-this-caller answer #20935

Closed

Description

@objectstack-fleet

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[0]). ⛔ Filed bare: grading and routing are triage's. ⛔ Not a claim.

⚠️ Disclosure discipline. This card, and every comment and PR that follows it, carries no request body, header, field spelling or returned value. The measuring dev keeps the evidence in private scratch space.

What was measured

Measured by #20917's dev on the analytics routes with the real SecurityPlugin, ObjectQL and SqlDriver, as a caller who sees one field only partially masked: a maskingRule field whose unmask requirement the caller does not hold. The measurement is private.

  • On the native-SQL strategy, that field as a grouped or filtered member is answered unmasked.
  • engine.find, engine.aggregate and the analytics ObjectQL strategy refuse it 403.
  • Class b too: the masking contract states that a masked caller cannot filter, sort, group or aggregate on the field.

Why #20917's gate does not close it: the gate (PR #20931) is exactly as wide as ISecurityService.getReadableFields, the published reader. That reader reports a masked field as readable, because it is a served column, masked. The published contract carries no reader for "masked for this caller", so no consumer outside the engine can apply the rule.

Seam: spec:FieldSchema.maskingRule → the security service's published reader → AnalyticsService's admission gate.

Direction (proposed, triage's to set)

  • The security contract publishes the "not queryable for this caller" answer beside getReadableFields (a contract member in the spec and plugin-security lanes), and the analytics gate asks it. ⛔ No second derivation of the masking rule in the analytics layer.
  • Pins: a masked-for-this-caller field as a grouped and as a filtered member answers the engine's 403 on both strategies. An unmasked reader of the same field is the control.

Reader who acts

Triage's first grade. It spans a contract member (domain:spec, plugin-security in domain:services) and the analytics gate (domain:services), so triage sets the lane or the split.

Dedupe (queries run before filing, closed included)

Dedupe words: analytics masked field native SQL · maskingRule getReadableFields queryable · masked field group filter analytics


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20955 → 83480c6a2f (p0); the card is done · 2026-09-30T23:43Z

    domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim 5919731088. ⚠️ The disclosure discipline holds.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatched: the changeset-wording follow-up (Part of this card) · 2026-10-01T00:22Z

    domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim 5919731088. ⚠️ The disclosure discipline holds.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 20935,
    "status": "done",
    "branch": "claude/issue-20935-changeset-wording",
    "pr": "#20991",
    "session": "session_01Sfe5YjBLwB9J3y8fvm2xq1 — this run's harness-stamped id (commit Claude-Session line); mode:subagent, so it is the seat's own id",
    "premise_still_valid": true,
    "summary": "Draft PR #20991 (Part of #20935, Clause-②: no) edits one file, .changeset/20935-analytics-masked-field-not-queryable.md, which is pending on main (base 05be352, head fa6f9f7, +6/-4). (1) The BREAKING banner now reads 'BREAKING for analytics queries that group, aggregate, filter or sort by a field the caller may only see masked: on a SQL deployment, and on POST /api/v1/analytics/sql whichever strategy serves the cube.' Evidence: generateSql() runs callCtx, the same admission as query(), before resolveStrategy, and the ObjectQL strategy's generateSql renders the statement itself without reaching the engine's query guards. (2) 'What is not affected' now opens with 'Unless the security service predates getQueryableFields or answers "no answer" (see New hook):' ahead of its three sentences, which are otherwise kept. Evidence: the plugin.ts bridge fallback is getReadableFields minus every field that declares a maskingRule, and it reads no caller property. getReadableFields gives a system context the full set, so a system caller is refused there too. Pinned by field-query-admission-gate.test.ts 'fails CLOSED for a security service that predates getQueryableFields', which also refuses SYSTEM. Scope: one qualifier covers the capability-holder sentence as well, because the same fallback over-refuses it (plugin.ts's comment says so). This is a bounded in-place fix. All four conditions hold: same defect class; mechanical, in the form the review record gave; same file, no other claim on it; no new verification surface. The PR body declares it. Kept as they were: the summary line, the changeset's Clause-② line, the ADR-0087 marker, and the BREAKING marker. Conflict named: the order says to keep the banner 'as it is' and also to correct line 11, which is the banner. I read that as keeping the BREAKING marker and correcting its scope words. breakingDeclaration on the head file still returns breaking:true with signals BREAKING, bang and clause-②-narrowing, and the disposition reads not-required (no-migration-prescription). skip-changeset: not applied. pr-automation.yml route 0, plus check-empty-changeset.mjs's DELIBERATE CORRECTION class and ruling D on #18375, refuse that label on a PR that edits an existing changeset. The PR body carries the written confirmation request, so Check Changeset stays red by design; it is not a required context. Clause-② re-read in scripts/pm/clause2-line.mjs: a text-only edit widens no accept set and adds no surface, so 'no'. An absent arm declares no direction, and no (narrowing) would declare a breaking change this PR does not make.",
    "tests": "Gate union at fa6f9f7 (git rev-parse --short HEAD after the final commit; no later push). node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack exited 0: tree objectstack-ai/objectstack at fa6f9f7, --repo holds, 1 path vs merge base 05be352, 19 commands. Results, each captured before any pipe — exit 0 (18): check-adr-0087-registration.mjs --base origin/main and --self-test; check-changeset-no-major.mjs --base origin/main ('This diff introduces no major bump'; the level axis is NOT APPLICABLE locally because there is no pull_request payload) and --self-test; check-closing-keyword-parity.mjs and --self-test; check-comment-mask-corpus.mjs; check-empty-changeset.mjs --self-test; pm/release-rehearsal-clone.mjs --self-test; pnpm check:changeset-gate-self-tests, check:driver-memory-census, check:gitlink-declared, check:nul-bytes ('OK (scanned 9602 text file(s) ... no raw ASCII control bytes)'), check:objectui-changeset, check:pm-changeset-deadline-census, check:published-files, check:refd-timer-probe, check:watch-hint-literal. Exit 1 (1, expected): check-empty-changeset.mjs --base origin/main says the file is 'present on the merge base and CHANGED by this PR', the DELIBERATE CORRECTION class, so the remedy is written confirmation on the PR. Extra: node scripts/check-changeset-fixed.mjs, whose roster is under .changeset, exited 0. Reconciliation: dispatch-gates --ran with the exit-coded record exited 0, '19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)'. The ADR-0087 gate prints '1 non-breaking changeset(s) seen'. That count is its skipped list, which also holds inherited-breaking files, and a direct breakingDeclaration call confirms the head file is still breaking. Control-byte self-scan of the changeset and the PR body: zero hits. PR body read-back: the relay reported 8062 bytes sent and 8062 stored, identical, and one REST GET returned draft:true, assignee os-justin, body byte-identical. NOT MEASURED, by design: type-check lanes and package tests, because the diff touches no TypeScript or package source; repo-level lint belongs to CI. Ablation: none — a text-only edit has nothing to ablate. Not run live: the echo behaviour is cited from code reading and the named pin, not from a running server.",
    "mcp_calls": "0",
    "api_writes": "3 — fleet-write relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches), each executed as objectstack-fleet[bot]: (1) pr_create, draft forced → POST /repos/objectstack-ai/objectstack/pulls, giving #20991 (run 36796883771); (2) label-write --assign os-justin → POST /repos//issues/20991/assignees (run 36796939546); (3) this os-dev-report comment through post-stamped.mjs → POST /repos//issues/20935/comments. Not REST writes: two git pushes (the empty branch as the claim marker and route probe, then fa6f9f7). Reads: three single REST GETs (comment 5922157430, comment 5921385686, pull 20991) plus the tools' own route, pace and label read-backs. Write budget before the first write: 12 of 40 in the trailing hour.",
    "open_questions": [
    {
    "question": "Ruling D route: the corrected note needs written confirmation on PR #20991 before it lands, and Check Changeset stays red. Who confirms?",
    "options": [
    "A — the seat confirms in writing on #20991, as the owner of the PR #20955 review adoption that ordered these two corrections",
    "B — escalate to the maintainer"
    ],
    "recommendation": "A, because the order and the review record are the seat's, the edit is two scope corrections that record already spelled out, and the PR touches no governed surface."
    },
    {
    "question": "The #20917 and #20933 notes carry the same over-narrow banner (see out_of_scope_findings), and release PR #20639 consumes all three when it merges. Correct them before it does?",
    "options": [
    "A — one more one-file follow-up per note (or one PR for both), under each card's own claim, before #20639 merges",
    "B — leave them; once released, correct only by amending the CHANGELOG entry in a docs-only PR"
    ],
    "recommendation": "A, because a pending note is cheap to correct now, while a released one can only be amended in its CHANGELOG entry and its tarball keeps the old text."
    }
    ],
    "out_of_scope_findings": [
    "class: a · reach: exception: release-text (pending changesets; #20639 publishes them) · The BREAKING banners of .changeset/20917-analytics-field-permission-gate.md ('BREAKING for analytics queries on a SQL deployment that read a field the caller may not read.') and .changeset/20933-analytics-relationship-path-admission.md ('... on a SQL deployment that read a related object through a relationship path the cube does not declare.') are over-narrow in the same way. Each change's gate sits in callCtx, which generateSql() shares, ahead of strategy choice, and the ObjectQL strategy's generateSql never reaches the engine's guards. So the /analytics/sql echo moved from printed to 403 on both strategies. Evidence for #20917: at its pre-merge parent 95555e7, git grep for getReadableFields or assertFieldsReadable over service-analytics src (non-test) exits 1 with zero hits; the control at 1571aed exits 0 with 3 hits in analytics-service.ts; and the parent's ObjectQL generateSql has no engine call. Evidence for #20933: at its parent 83480c6, queryObjects equals cubeObjects (base plus declared joins only) and feeds assertReadAdmitted in callCtx; the head adds relationship-path objects. Read, not run live. Not edited, per the order. Dedupe words: changeset banner SQL deployment; analytics/sql echo both strategies; 20917 changeset wording; 20933 changeset wording.",
    "carrier: none (承接者:无) · noted in PR #20991 Acceptance notes, not filed · Two wording observations in the 20935 note, left untouched under 'no new claims'. (i) The banner does not say that the fallback also refuses callers who see a field unmasked; the corrected 'What is not affected' qualifier and 'New hook' cover it. (ii) 'New hook' says 'for every caller', but under the fallback a reader that also answers 'no answer' judges no field. Class: none; text precision, not a defect.",
    "carrier: none (承接者:无) · noted, not filed · scripts/check-adr-0087-registration.mjs prints 'N non-breaking changeset(s) seen' from a skipped list that also holds files breaking at the merge base (inherited), so a PR editing a breaking pending note is reported as having seen a 'non-breaking' one. Output wording only; the verdict is right."
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    The changeset-wording follow-up is closed by the maintainer's answer

    domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:specpriority: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