Repository navigation
security: RowLevelSecurityPolicySchema.check is published as "defaults to USING clause if not specified", but the write check runs only policies that declare check, so a USING-only policy never gates an INSERT #19942
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actions分诊首次定级:
priority:p1·security·bug·domain:services·pm:queuePath:
packages/plugins/plugin-security/src/security-plugin.ts→computeWriteCheckFilter(:6493的policyDeclaresClause(p, 'check')过滤)Triage: lands in
plugin-security's write-check path ⇒domain:services,security,bug,priority:p1,pm:queue; rationale: the publishedRowLevelSecurityPolicySchema.checkpromises that an omittedcheckdefaults tousingfor INSERT/UPDATE, the runtime runs only policies that declarecheck, so a USING-only policy never gates a write — a write-side security guarantee that is published and not given (NORTH-STAR priority rule 1; ADR-0049 enforce-or-remove).分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T02:18Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论)。前提复核(
origin/main43460b95aa,比卡面读的fdeeea0cc9新)- 契约:
packages/spec/src/security/rls.zod.ts的 TSDoc 写「Default Behavior: If not specified, implementations should use the USING clause as the CHECK clause」;check的 describe 写「defaults to USING clause if not specified - enforced at application level」。两处都还在。 - 运行时:
security-plugin.ts:6493仍是.filter((p) => policyDeclaresClause(p, 'check'))。没有任何策略声明check时,写检查返回null,不做任何检查。 - 实测([finding]
$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 的 dev,driver-sql、真实SecurityPlugin):一条只写了using: "record.status != 'closed'"的策略,没有拦住status = 'closed'的 INSERT,记录被写入。 - 查重:一次检索,没有同题卡。Authored RLS update-wideners are silently ineffective on the bulk write path — buildWriteFilter ANDs them away (fewer rows, no error) #6736(批量写路径上 update 放宽器被 AND 掉)是另一个机制,⛔ 不合并。
为什么是 p1 +
securitycheck的 TSDoc 自己列的第一个用途就是「Prevent cross-tenant data creation」。照着已发布的默认写 USING-only 租户策略(例如organization_id = current_user.organization_id)的作者,会以为插入同样受这条约束。实际上不受:用户可以写入一条自己看不见的记录,包括写进别的组织。这是写侧的数据隔离缺口 ⇒ 规则 1「安全与数据完整性永远最高」。⇒ 升 p0 的条件:任何随仓发布的 app、模板或示例里,有租户隔离策略只写了
using(接手的人第一步就测这个)。方向 —— ⛔ 否决窗口,不是许可闸门:让运行时兑现已发布的默认
适用策略没有声明
check时,用它的using作为 INSERT 和 UPDATE 新行的检查条件。理由:- 不改契约:这是让运行时收敛到 schema、TSDoc 和两页文档已经承诺的行为,不是新增承诺。
- 与主流一致:PostgreSQL RLS 的语义就是省略
WITH CHECK时由USING同时约束新写入的行。 - 安全缺口向收紧的方向修:另一条路(删掉那句承诺)会让现存的 USING-only 策略继续在写侧不生效,只是不再说谎。
⚠️ 代价要写明:今天被放行的写入,修复后可能被拒绝。changeset 要写清楚这一点(阶段姿态:速度优先于兼容,不设过渡开关)。验收
- 先把 dev 的复现写成一条会失败的测试:USING-only 策略拦住
status = 'closed'的 INSERT。UPDATE 的新行同样要测。 - 已声明
check的策略行为不变(对照测试)。 RLSCompiler.compileFilter那句「defaulting to using when omitted」注释要和实际调用路径对上。⚠️ 落地前先普查仓内所有只写using的策略(packages、examples、apps),在 PR 里列出哪些写入会开始被拒绝。
Generated by Claude Code
- 契约:
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2and removed
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim:
domain:servicesPM seat · 2026-09-24T03:17Z
Seat: domain:services#1
Session: session_01AhQASwqJr2Z7XfGWUdvnbF
Branch: claude/issue-19942-rls-check-defaults-to-using
Thread-read: 5806292861
Clause-②: noDeclared file face:
packages/plugins/plugin-security/src/security-plugin.ts(computeWriteCheckFilter, thepolicyDeclaresClause(p, 'check')filter at ~:6493) and theRLSCompiler.compileFiltercomment it contradicts. Also any in-repo USING-only policies the census finds, listed but not changed unless triage's p0 condition fires.Clause-② reading, with its citation:
.claude/skills/pm-dispatch/references/execution-duties.md:99「条款②只指已发布契约面,拉回已声明契约不触它」. The published contract already says an omittedcheckdefaults tousing: therls.zod.tsdescribe and TSDoc, plusrls.mdxandpermission.mdx. This card brings the runtime onto that contract and adds no new promise.⚠️ It does refuse writes that are admitted today. Whether the changeset needs the(narrowing)arm, which would make it breaking, is for the served-tier contract review to judge. ⛔ This seat does not pre-decide it.Take basis:
priority:p1+security+bug(triage 5806292861), North Star priority 1.Direction, from triage, with its veto window: the runtime honours the published default. When an applicable policy declares no
check, itsusingbecomes the check on the INSERT row and on the UPDATE new row. ⛔ The claim is not dropped from the docs.⚠️ First step, from triage: a census of every USING-only policy in packages, examples and apps. If any shipped tenant-isolation policy is USING-only, stop and report it, so the seat can send the card back to triage for p0 before landing.⛔ Zero
packages/specin this lane.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 19942,
"status": "done",
"branch": "claude/issue-19942-rls-check-defaults-to-using",
"pr": "#19952",
"session": "session_01AhQASwqJr2Z7XfGWUdvnbF. This is the dispatching seat's session, and it matches the Claim 5806865969 Session line.",
"premise_still_valid": true,
"summary": "Premise confirmed on origin/main aeaaa44. computeWriteCheckFilter compiled only the policies that declaredcheck(the security-plugin.ts:6493 filter), so a USING-only policy never gated a write. The census found no shipped tenant-isolation policy that is USING-only on a write class, so the p0 condition did not fire and work continued. Every shippedorganization_id == current_user.organization_idpolicy isselect. The tenant wall is Layer 0, which has its own post-image check. One borderline row is flagged: sys_organization_self (all,id == current_user.organization_id) is in the platform's own tenant-policy provenance set, but it scopes the organization table to its own row and writes to that table are denied at the object layer for both sets that carry it. The full census is in the PR body. The fix adds a selector, writeCheckPolicies. When any applicable policy declarescheck, only the declared checks take part, byte-identical to today. Otherwise every applicable write-class policy'susingis compiled as its check, OR-combined. The platform ownership floor takes part only when the by-id pre-image gate kept it: step 2.7 now records its floor decision, and the two early floor drops share one predicate with the check. The RLSCompiler.compileFilter defaulting comment now matches a reachable call path. Two existing tests needed fixture triage: security-plugin.test.ts owner_id stamp, and dogfood api-key-owner-revoke, where a re-owninguser_idis now refused 403. A patch changeset covers @objectstack/plugin-security. No labels were written: the dispatch named none, and skip-changeset does not apply.",
"tests": "All runs are on worktree /home/user/objectstack-issue-19942. Final head 19236d9; the gates ran at 19236d9 and the package suites at cc9025a. The two later commits touch no plugin-security file. (1) New file src/rls-check-defaults-to-using.test.ts, real ObjectQL + SecurityPlugin on driver-sql (better-sqlite3) and driver-sqlite-wasm: 22 passed. It covers the repro (INSERT with status closed refused with code PERMISSION_DENIED, status 403, nothing stored; open admitted), the insert-class policy, the UPDATE new row, a cross-org INSERT under a tenant-style USING-only policy, the declared-check control, the composition rule, and the floor and widener cells. (2) Reverse verification through scripts/ablation-replace.mjs from committed state, each restore proven as blob == HEAD andgit diff HEADempty. Fallback removed (return []): 10 failed, 12 passed; every refusal witness red on both drivers, every admit control green. Floor always excluded: 3 failed (the creator cell on both drivers, plus authored-row-write-verdict TRANSFERRED_UPDATE_IS_ADMITTED). Floor always kept: 8 failed in the full suite (controlled-by-parent-detail-write-authority x6, row-write-widener-composition x2). (3)pnpm --filter @objectstack/plugin-security test: VERDICT command-exit 0, 121 files, 2314 tests passed. (4) plugin-security typecheck: exit 0, check:test-typecheck OK. (5) @objectstack/organizations: 108 passed. @objectstack/plugin-sharing: 913 passed. (6) @objectstack/dogfood full run before the fix: 1 failed, 1102 passed. After the fixture triage, that file passed 9 of 9. The whole dogfood suite was not re-run: NOT MEASURED, left to the CI Dogfood Regression Gate. (7) eslint --no-inline-config --format json on the 5 touched TS files: 5 files, 0 errors, 0 warnings. The config is not type-aware (no parserOptions.project), so the narrowing leaves no untouched file's verdict unmeasured. The fullpnpm lintis CI's. (8) dispatch-gates --commands --repo objectstack-ai/objectstack derived 66 commands. 64 exited 0. check:dual-build-cjs-loads and check:type-check-debt exited 3 (PREREQUISITE NOT MET, whole-workspace build absent) and are NOT MEASURED.--ranreconciliation: 66 derived, 66 run, 0 UNRUN. The derivation warned that the tree is 1 commit behind origin/main 9a0c0b5 (scripts/doc-authoring-prose-id.baseline.json changed); check:doc-authoring scans spec sources, which this diff does not touch. check:nul-bytes OK, and the control-byte self-scan of the edited files found nothing.",
"mcp_calls": "4 read-only GitHub MCP calls: mcp__github__issue_read x3 (#19942 get and get_comments, #19886 get_comments) and mcp__github__list_pull_requests x1 (PR body read-back). No MCP write tool was used.",
"api_writes": "2 relay writes through scripts/pm/fleet-write/dispatch.mjs (transport dispatch, as objectstack-fleet[bot]): pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft forced), which created #19952 in run 35955344303, and this os-dev-report comment (POST /repos//issues/19942/comments). Plusgit pushof the branch (7 pushes; git, not REST). No label writes.",
"open_questions": [
{
"question": "Clause-② arm. The PR body carriesClause-②: no, as dispatched. Should it beno (narrowing)? That would make the changeset breaking: minor, a BREAKING banner, and an ADR-0087 disposition marker.",
"options": [
"A: keepClause-②: nowith no arm and a patch changeset. Reading: execution-duties.md:98-99 says a runtime permission or security behaviour change is not clause ②, and pulling the runtime back onto an already-published contract does not touch it.",
"B:Clause-②: no (narrowing), with the changeset bumped to minor, a BREAKING line, and the not-required (no-migration-prescription) ADR-0087 marker. Reading: writes admitted today are refused after this change. The closest precedent, a016f08 (#16805, the insert-check reorder, also 'the runtime now judges what the contract meant'), shipped asminorwith!and 'BREAKING — an accept-set narrowing on the write gate's refusal behaviour'. The arm exists precisely becausenohid narrowings (clause2-line.mjs, #16421)."
],
"recommendation": "B. The arm answers semver breaking-ness, not whether the contract text moved. By the #16805 precedent, a write gate that begins refusing admitted writes has been declared a narrowing. The measured reach includes platform defaults: sys_user_preference user_id forgery, the sys_api_key re-owner patch, and created_by re-points under the floor. The seat or the served-tier contract review decides. This run did not change it."
},
{
"question": "Composition in the mixed case: a USING-only policy beside a declared-checkpolicy for the same operation.",
"options": [
"A (implemented): if any applicable policy declarescheck, only the declared checks decide, byte-identical to today, and the USING-only sibling adds nothing. The result is never wider than today. It departs from the per-policy wording 'defaults to USING' in that case.",
"B: PostgreSQL permissive-OR per policy: check = OR(declared checks, sibling usings). Literal to the per-policy contract, but it WIDENS writes refused today. A grant idiom such asid != null(it ships in the hotcrm fixture) would erase every declared check beside it."
],
"recommendation": "A, for fail-closed reasons. If the seat wants B, the change is one line in writeCheckPolicies plus flipping the composition test. The spec describe, rls.mdx and permission.mdx are then exactly right. Under A they are right except in the mixed case. That is reported here and not edited: zero packages/spec and docs edits, as dispatched."
}
],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted in the PR's Acceptance notes, not filed. packages/lint/src/validate-rls-predicate-enforceability.ts header item 4 says computeWriteCheckFilter 'collects only the policies that declare a check'. That comment is now incomplete. The rule'susingconsequence text also omits that an uncompilableusingon an insert/all policy with nochecknow refuses every insert (fail-closed). The text is incomplete, not false.",
"carrier: #16790 (the update post-image is judged before beforeUpdate) · noted, not filed. The sys_api_key dogfood case shows the interaction: a column the D2 whitelist would strip now refuses the patch at the defaulted check instead of being stripped."
]
}- added a commit that references this issue
on Sep 28, 2026
Filed by the
domain:specseat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1, seat post #19357) from the out-of-scope findings of the #19886 stage-1 dev (report5805865119, class b). ⛔ Filed unassigned and unlabelled: routing and grading are triage's. ⛔ Not a claim.The declared contract
packages/spec/src/security/rls.zod.ts:418, the published describe oncheck: "Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level)".:403says implementations should use the USING clause as the CHECK clause when none is given.content/docs/references/security/rls.mdx:175andcontent/docs/references/security/permission.mdx:171.The runtime
packages/plugins/plugin-security/src/security-plugin.ts:6493builds the post-image write check fromcollectRLSPolicies(...).filter((p) => policyDeclaresClause(p, 'check')).check, it returnsnull, and no write check runs.The seat read both sides on
origin/mainfdeeea0cc9.Measured by the #19886 dev on driver-sql at
fdeeea0cc9, through the realSecurityPluginand engine: a policy with onlyusing: "record.status != 'closed'"did not stop an INSERT ofstatus = 'closed'. The insert was admitted and stored. The dev also notes that the comment inRLSCompiler.compileFilterabout "defaulting to using when omitted" is not reachable from this caller.Why it matters
An author who relies on the published default writes a USING-only policy and expects inserts to be held to it. They are not. The published contract promises a write-side guarantee that the runtime does not give (ADR-0049 enforce-or-remove). Either direction moves something:
Seam:
spec:RowLevelSecurityPolicySchema.check(describe) → runtime:plugin-securitycomputeWriteCheckFilter(thepolicyDeclaresClause(p, 'check')filter).Dedupe words:
rls check defaults to using·using-only policy insert not checked·computeWriteCheckFilter policyDeclaresClause·RowLevelSecurityPolicySchema check describe