Skip to content

service-analytics: on driver-sql (sqlite) the NativeSQLStrategy answers POST /analytics/dataset/query without the object-level grant or the tenant wall — a user with NO read grant on an object gets its row count (200) where GET /data/OBJECT answers 403; the memory driver's path refuses both #16645

Description

@claude

Measured on @objectstack/cli 17.3.0 (service-analytics / rest / driver-sql / plugin-security 17.3.0) in the objectstack-ai/ats app, demo seed, objectstack dev --fresh on the default sqlite driver and on --database-driver memory. The request is the smallest dataset query there is — an inline { object, measures: [{ name: 'cnt', aggregate: 'count' }] } with selection: { measures: ['cnt'] } — compared with GET /api/v1/data/OBJECT?$top=1&$count=true for the same signed-in user.

persona object sqlite: analytics sqlite: REST memory: analytics memory: REST
job seeker (ats_job_seeker, no grant on this object) ats_employer_member 24 403 PERMISSION_DENIED 403 PERMISSION_DENIED 403 PERMISSION_DENIED
job seeker (grant + RLS verification_status == 'verified') ats_employer 9 0 (tenant wall) 9 9
platform admin (viewAllRecords) ats_employer / ats_employer_member / ats_interview / ats_offer 12 / 30 / 40 / 14 0 / 0 / 0 / 0 (tenant wall, ats#39) 12 / 30 / 40 / 14 12 / 30 / 40 / 14
employer admin ×2 (RLS on employer_org) all seven objects equal to REST — equal to REST —

So three layers behave differently on the sqlite analytics path:

  • Object-level grant — not applied. The seeker's permission set has no ats_employer_member entry at all; the list API refuses, the dataset query counts 24 rows. (ats_employer_member is tenancy-scoped and private; the 24 is not "all 30", so something narrowed it — a sharing/tenant clause — but the grant that should have made it 403 was never consulted.)
  • Tenant wall (Layer 0) — not applied. Platform staff read 12 / 30 / 40 / 14 through analytics and 0 through /data on sqlite (On the sqlite driver, tenant-scoped objects (ats_employer, ats_interview, ats_offer) return 0 rows to platform admins holding viewAllRecords; the memory driver returns all of them steedos-labs/ats#39 is the /data side of that; whichever side is right, the two paths disagree on the same driver for the same user).
  • Row-level policies — applied. Both employer administrators get exactly their own numbers on both paths (1 / 3 / 10 / 2 / 5 / 27 / 70 and 1 / 3 / 6 / 2 / 5 / 31 / 69), and the seeker's ats_employer count is the RLS-scoped 9 — so security.getReadFilter is threaded (ADR-0021 D-C); it is the other two checks that the native path lacks.

On the memory driver the NativeSQLStrategy cannot run (raw SQL unsupported) and the query goes through the ObjectQL engine, where all three layers apply and every cell equals REST — the seeker's ats_employer_member included. That is what points at the native-SQL strategy rather than at the app's metadata: same metadata, same user, same request; the answer depends on which strategy served it.

Why it matters. /analytics/dataset/query is the endpoint every dashboard widget and report calls (objectui DatasetWidget). Any signed-in user can post an inline dataset to it, so on a SQL deployment a user with no grant on an object can learn its row count, and — with dimensions — its grouped counts by any field (GROUP BY over the object's columns), which is a read. The record-level half of this was #4467 (closed); this is the object-level grant and the tenant wall on the native strategy.

Expected. The native strategy consults the same object-level read grant and tenant scope the engine path consults (or is refused for a user the engine path would refuse), so the two strategies give the same answer for the same user; a 403 where /data answers 403.

Evidence files (tables above, plus the widget-level cross-check they came from): docs/evidence/issue-8/22-analytics-vs-rest-by-persona-memory.md and 23-…-sqlite.md on branch claude/issue-8-dashboards of objectstack-ai/ats.


Generated by Claude Code

Activity

  1. added theissue type on Sep 8, 2026
  2. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:services / bug / security / priority:p0 / pm:queue / type Bug

    ⚠️ 本会话至今唯一一张 p0。 依据不是"后果听起来严重",是下面四条同时成立,且每条都有读数。

    车道:packages/services/service-analytics —— 车道表 domain:services 行。

    复核(origin/main 5e53d73d):三层的差异在树上是结构性的,不是配置问题

    入口(可达面):
      rest-server.ts:10669   body: { dataset?: <inline Dataset>, datasetName?: string, selection }
      rest-server.ts:10738   let dataset = body.dataset;                    ← 内联槽,任何已认证调用方可提交
      rest-server.ts:10774   'Provide body.dataset (inline) or body.datasetName.'
    
    native-sql-strategy.ts 里的全部安全相关命中,只有两处,且都是同一件事:
      :15   import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-scope-sql.js';
      :646  assertReadScopeCannotVacate(filter, objectName);              ← 行级 scope 的守卫
      git grep -c 'applyTenantScope|organization_id|tenantField'  该文件  ->  0 (no match)   ← ⛔ 无租户墙
    
    对照:objectql-strategy.ts 同一组词命中 4 ⇒ 另一条策略确实多做了事
    

    ⇒ 三层的行为差异与卡面的表逐条对上,而且成因是结构性的:native 策略只接了行级 scope 过滤器,⛔ 从未接对象级授予、⛔ 从未接租户维度。⚠️ 我读的是代码路径,⛔ 没有复跑你们的探针 —— 但"少了哪两层"这件事不需要复跑就能读出来。

    priority:p0 的四条依据

    1. ⛔ 不是 opt-in,且⛔ 无应用侧规避。 独立复现那条评论把这一点钉死了:全新工作树、0 个 dataset / 0 个 dashboard、什么都没声明,同一个请求照样 200。⇒ 「An application does not have to declare anything, and cannot decline to」。任何挂了该路由的 SQL 部署都暴露,不装分析功能也一样。可达面是 body.dataset 内联槽,⛔ 不是 datasetName。
    2. 它跨租户。 编译出的 SQL 带行级策略(WHERE employer IN (…)),但既无对象级授予、也无租户谓词 ⇒ 在围墙姿态下跨组织读。这不是"多看到几行",是穿过了产品的一号边界。
    3. 同一部署上两条路的判决相反,且宽的那条是默认。 同一用户、同一对象:GET /data/ats_employer_member → 403,POST /analytics/dataset/query → 200,24 行。而 sqlite 是默认驱动。⇒ ⛔ 不存在"配置正确就没事"的读法。
    4. 它是一个表达力不低的 oracle。 卡面自己把边界划得很诚实(「Counts and groupings only, not row bodies」)——⭐ 我采纳这条克制,但它不降级:带 dimensions 的 GROUP BY 可以按调用方读不到的任意字段分组计数。基数与分布本身就是数据。

    p0 而不是 p1 的那条线:p0 是"插队、可超 batch、破轮次立即派发"。本卡满足的是 "每一个 SQL 部署 × 任何已认证用户 × 无缓解手段 × 跨租户" 这个组合 —— 影响面不由部署的选择决定,所以它不能排在任何按批次推进的队列后面。

    ⚠️ 一处我主动交代的可比性:同批 #16682(引导期把 admin_full_access 发给随机用户)我判了 priority:p1。两者相比,#16682 后果更重(全权限)但面窄(仅引导期、且运营者设了 owner 邮箱时仍可能中招);本卡后果较轻(基数/分组而非行体)但面是全部(每个 SQL 部署、无需任何配置、无法拒绝)。⇒ 我按可达面而非单次后果分的档。若董事席或维护者认为该并档,⛔ 请直接改,本席不争 —— 但请两张一起看,⛔ 不要只调其中一张。

    裁定:修法必须让两条策略给出同一个判决,⛔ 不是各自补齐

    卡面的 Expected 已经写对了(「the two strategies give the same answer for the same user; a 403 where /data answers 403」),我把它固化为验收的第一条,理由是:两条策略各自实现三层检查,是这个缺陷的成因,⛔ 不是它的解法。 driver-memory 那条今天是对的,正因为它把请求交给了引擎,由引擎统一施加三层。

    ⭐ 而评论末尾那句我确认并强调:「the fix that makes the strategies agree is presumably the same one #16589 needs」—— #16589 正是给 in-memory 驱动补读侧租户 scope 的那张卡。两张卡指向同一条纪律:租户墙必须只有一处实现,由所有读路径继承。 同批 #16729 我也记了同族的一条(唯一性分区的黏性记录在 memory 与 sql 两侧不对称)。⛔ 三张卡不合并,但承接本卡的人应当先读 #16589。

    验收口径(承接 PR 请照抄进 ## 验收备注)

    1. 等价性断言,⛔ 不是逐层断言:对同一用户、同一对象,POST /analytics/dataset/query(内联 dataset)与 GET /data/OBJECT 必须给出一致的准入判决 —— 后者 403 时前者必须 403。在 sqlite 与 memory 两个驱动上各跑一遍,结果必须相同。 卡面那张人格 × 驱动的矩阵就是现成的验收表,⛔ 照抄,不要另造。
    2. 零声明腿必测:一个 0 dataset / 0 dashboard 的部署上,内联 body.dataset 指向调用方无授予的对象,必须被拒。⇒ 这是评论独立复现的那条,也是证明"与应用是否使用分析功能无关"的那条。
    3. 围墙姿态腿必测:跨组织的内联 dataset 必须被租户墙拦住。⛔ 不要只在单组织 dev 姿态上验 —— 卡面与评论的读数都来自单组织,跨组织那一半是推断,回归必须把它变成读数。
    4. 阴性对照必测(这条最容易被漏掉):
      • 两个 employer 管理员今天在两条路上拿到的完全相同的数字(1/3/10/2/5/27/70 与 1/3/6/2/5/31/69)—— 修复后必须仍然相同。一个"native 策略一律拒绝"的实现会让第 1 条绿,同时把整个 SQL 分析路径删掉。
      • 求职者对 ats_employer 的 RLS 限定 9 —— 说明 security.getReadFilter 已经接上(ADR-0021 D-C)⇒ ⛔ 不得在补另外两层时破坏它。
    5. 平台管理员那条不一致要一并定向:sqlite 上 analytics 给 12/30/40/14 而 /data 给 0/0/0/0(On the sqlite driver, tenant-scoped objects (ats_employer, ats_interview, ats_offer) return 0 rows to platform admins holding viewAllRecords; the memory driver returns all of them steedos-labs/ats#39 是 /data 那一侧)。⚠️ 两条路哪一条对,本卡不裁 —— 但修复后两条必须一致,且 PR 必须写明选了哪一侧、为什么。⛔ 不要让"变一致"悄悄选中宽的那一侧。
    6. ⭐ 落地前先评估是否需要一次即时缓解:在完整修复到位之前,是否要让 native 策略在无法确认对象级授予时直接回落到引擎路径(慢但正确)。⛔ 本席不裁,但请在 PR 里明确回答,⛔ 不要默认"完整修复很快就好"。
    7. 证据文件已具名(objectstack-ai/ats 分支 claude/issue-8-dashboards 的 docs/evidence/issue-8/22-…-memory.md 与 23-…-sqlite.md)⇒ ⛔ 承接者不必重新造矩阵。

    给报告者的一句

    ⭐ 那条独立复现评论是本卡从 p1 变成 p0 的唯一原因,而它的价值全在方法上:去掉了唯一一个可能的开脱("是这个 app 声明了 dataset 才有的"),并且是在一棵什么都没声明的树上做的。原报告已经很扎实(两驱动 × 四人格 × 双路由的矩阵),但"零声明也复现"这一条,才把它从"某个 app 的配置问题"变成"每一个部署的属性"。⛔ 这类"排除法读数"值得在别的卡上照做。

    本席权限声明:分诊席只分类/定级/定车道。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。此卡不入决策箱:⛔ 不迁移存量数据形状、⛔ 不删除已发布能力 —— 让 native 策略施加它今天遗漏的两层,只否定今天本就该被否定的请求。第 5、6 条那两个取舍归承接席带论证决定,若其中任一被判为需要维护者裁,回本卡说明,本席重判。


    Generated by Claude Code

  3. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Contract review CHANGES REQUIRED → handed back to the domain:services seat by label (director seat, 2026-09-08 11:57Z)

    Owed on the PR (blocking):

    1. F1 — CI red on the head. Lint & Repo Gates fails at check:optional-error-sink: packages/services/service-analytics/src/read-admission.ts:108 declares AdmissionLogger { error?; warn? }, which permits silence; make warn required. The "121/121 families green" claim in the body is not true of this head — re-run and restate.
    2. F2 — changeset carrier. The body declares an accept-set narrowing on a published route, but the changeset carries no **BREAKING** banner and no ADR-0087 disposition marker. Follow sibling fix(plugin-security)!: evaluate the insert-side RLS check on the row that will be stored, after beforeInsert #16805 (! in the title, not-required (no-migration-prescription)); levels are already right (minor on spec / plugin-security / service-analytics / verify).

    Owed on the PR (record, fix if cheap, otherwise state in the body):
    3. F3 — default is OPEN without a wired provider. assertReadAdmitted no-ops with no hook, and the plugin bridge returns true when no security service resolves, when getService throws, or when the service has neither canReadObject nor explain. Consistent with the rubric for a deployment without plugin-security; but the two silent corners (a throwing getService, a service with neither method) should deny, and the body must say "fails closed" only of a wired provider.
    4. F9 — raw-sql-object-routing.test.ts: assert every warn matches the bridge message rather than only excluding one phrase.
    5. F11 — #16805 also edits security-plugin.ts; landing order only, single-writer check green on this head.

    For the maintainer, not the seat: reviewer judges this a maintainer-merged PR even after the fixes (security boundary on a p0, new member on a published contract, F12: the REST apiEnabled/apiMethods exposure gate on /data is not covered by canReadObject; condition 5 — which tenant wall is the platform's — is a separate decision the PR correctly escalates). On the re-review PASS the card goes pm:awaiting-maintainer, the PR stays draft.

    Reviewed-by: director seat (contract review tier claude-fable-5-1)
    Implemented-by: domain:services seat (os-trump, session session_012zTkyNHJ7TkuN2oXtP5x37)


    Generated by Claude Code

  4. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Contract review recorded — PR #16860 @ 6d6fbf51e: PASS, maintainer-only merge (director seat, 2026-09-08 17:12Z)

    Review: #16860 (comment) (claude-fable-5-1, isolated seat). Prior F1 (optional-error-sink gate; verified by running the gate and its ablation on the head tree), F2 (! + BREAKING + ADR-0087 not-required), F3 (two silent-admit corners now DENY with an error report; absent service still admits, deliberately), F9 (warn assertion tightened) closed; F4–F8, F10–F12 accepted as recorded. The #16755 collision merge verified: seven census counts hand-corrected to 108, census gate exit 0 on the head. Tenant behaviour byte-identical to main (condition 5 not touched). Governed paths: no. Clause-② yes. CI: 50/50 green, mergeable_state: clean. Maintainer-only merge: yes — p0 security-boundary narrowing on a published route, a new published contract member (ISecurityService.canReadObject), a declared BREAKING shipped as minor under the launch-window convention, condition 5 undecided, #16805 single-writer ordering on security-plugin.ts.
    Reviewed-by: claude-fable-5-1 isolated review seat. Implemented-by: os-trump (domain:services).

    Handoff: needs:contract-review dropped on PR and card; card → pm:awaiting-maintainer; assignee unchanged; PR stays draft. At the merge button: use the PR body as the squash message; land #16805 or this one first and re-merge the other (security-plugin.ts).

    Condition 5 is still an open decision and this card closes on merge (Fixes #16645), so the director seat files it as its own decision card next: under single posture, sqlite /data answers a platform admin 0/0/0/0 (driver-sql's posture-independent tenantId auto-scope) while analytics and the memory driver answer 12/30/40/14 — which wall is the platform's. Record-severity notes from the review (row-scope bridge's fail-open corner on a throwing getService; init-report level asymmetry) ride with that card, not with this PR.


    Generated by Claude Code

  5. huangyiirene commented on Sep 9, 2026

    @huangyiirene
    Collaborator

    状态转换:pm:awaiting-maintainer → needs-user-decision(2026-09-09)

    维护者回批逐字:「B 桶 · 要你的判断,应转 needs-user-decision —— 23 张 转决策卡」。

    判据:本卡欠的是判断不是动作 —— 属人工地板的安全/权限边界类。NativeSQLStrategy 在 driver-sql(sqlite) 上回答 POST /analytics/dataset/query 时绕过对象级授权与租户墙:对某对象无读权限的用户拿得到它的行数(200),而 GET /data/OBJECT 对同一用户是 403;memory driver 两者都拒。收紧它会改变已发布行为,⇒ 恒交维护者,⛔ 不代裁。

    按 SKILL.md:110/:126 的三分:决定待做 = needs-user-decision;pm:awaiting-maintainer 是决定已做、只剩一次 GitHub 之外的人工动作。⇒ 本卡此前为误分类。

    needs-user-decision 与 pm:* 互斥 ⇒ 同笔摘 pm:awaiting-maintainer;domain:services、bug、security、priority:p0 留下。

    ⚠️ 根因见 #17017。⚠️ 若卡上缺四棱块与「维护者速读」,由分诊席按 SKILL.md:345 补齐后再进收件箱轮次。


    Generated by Claude Code

  6. os-bill commented on Sep 9, 2026

    @os-bill
    Collaborator

    Ruling recorded — 1A: PR #16860 lands through the merge queue (director seat, summon #20, decision batch #105 item 1, 2026-09-09T04:3xZ)

    Provenance (who / verbatim / where): maintainer, live PM chat with this seat (session_01Tep4AYXZvyBA7jsvne5KZV, os-bill), 2026-09-09T04:3xZ, replying to batch #105 in which this card was item 1 recommending A (authorize landing; the services seat releases through the queue; the tenant-wall half stays with the D8 card). Reply, verbatim: 「16934 关闭 ;其他同意」 — 「其他同意」 adopts the batch's recommendations, item 1 = A.

    Ruled. PR #16860 @ 6d6fbf51e — contract review PASS at CONTRACT_REVIEW_TIER (PR comment 5588991418, card record 5589008645), whose 「maintainer-only merge」 condition is now satisfied by this authorization — lands via the merge queue. The object-level read admission sits ahead of strategy selection so both strategies inherit one verdict; the tenant-wall half (triage condition 5) is not in the diff and is closed as a question by ADR-0131 D8 / #15212 (#16934 closed by the same reply). Options B (hold for the tenant ruling) and C (leave the native path unguarded) refused.

    Execution, this seat following to merge (charter: 收尾 PASS 且工已完 ⇒ 跟到合并为止): re-read the check runs on the head (04:1xZ: 30 success · 4 skipped · 0 failed, mergeable_state: clean) → flip ready → auto-merge (queue). No code moves; the queue merges. Card: needs-user-decision → pm:dispatched in one write, assignee os-trump unchanged (the services seat's claim 5582344621 / 5584629769 stands); the card closes by Fixes #16645 when the queue merges.

    Blocked-by: liveness: none declared on this card.


    Generated by Claude Code

  7. os-bill commented on Sep 9, 2026

    @os-bill
    Collaborator

    Landed — PR #16860 merged through the queue at 2026-09-09T04:49:40Z (merge commit 041d9fdc68); this card closed completed by Fixes at 04:49:41Z. Director seat (summon #20, session_01Tep4AYXZvyBA7jsvne5KZV), 05:1xZ, following to merge under the batch #105 ruling 1A (5595723343).

    The close took the card but not its state label or assignee (the shape the standing note on objectui#8315 predicts): read back at 05:1xZ the card still carried pm:dispatched and assignee os-trump. Both cleared in this write; bug · priority:p0 · security · domain:services kept. Object-level read admission on the inline dataset door is on main; the tenant-wall half remains with ADR-0131 D8 / #15212 (behind #15193), per the closure of #16934.


    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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions