security(rls): refuse RESERVED_RLS_MEMBERSHIP_KEYS by name at the RLS compiler merge - #17289
security(rls): refuse RESERVED_RLS_MEMBERSHIP_KEYS by name at the RLS compiler merge#17289os-trump wants to merge 2 commits into
Conversation
…piler merge `RLSCompiler.compileFilter` merged a caller-supplied `rlsMembership` entry whenever `userCtx[key] === undefined` — a test of whether the KERNEL resolved a value on this request, not of whether the key is reserved. With the kernel value absent, all six `RESERVED_RLS_MEMBERSHIP_KEYS` let the bag define the authorization vocabulary, turning a fail-closed deny (zero rows) into a satisfiable filter over caller-chosen values. The merge now refuses the reserved names outright. `stageRlsMembership` screens only a registered resolver's answer, and returns at its first line when no resolver is registered, so the compiler — the one seam both the read (`using`) and write (`check`) faces pass through — is where the refusal has to live. Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 Co-authored-by: Claude <noreply@anthropic.com>
…text
`id` resolves from `ExecutionContext.userId`, so a shared `{ userId: 'usr_a' }`
base made the `id` cell a kernel-PRESENT cell wearing an absent cell's name. It
passed the byte-identity assertion for the wrong reason; only the fail-closed
assertion beside it caught the substitution.
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f542c29a5667650ad52ea9f0360eb0f6a5ee5e60 && git checkout f542c29a5667650ad52ea9f0360eb0f6a5ee5e60
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3cbcedb6291cc59a30dfda2961c8e526a775e1ed c8b7c1ca1c38cc0ec259b11dd7cd2afcf7abaa91 && git checkout -B drift-repro 3cbcedb6291cc59a30dfda2961c8e526a775e1ed && git merge --no-ff c8b7c1ca1c38cc0ec259b11dd7cd2afcf7abaa91
node scripts/docs-audit/affected-docs.mjs --json 3cbcedb6291cc59a30dfda2961c8e526a775e1ed |
PM 复核(
|
| 断言 | 本席量到的 |
|---|---|
| 生产改动 | rls-compiler.ts 里 一行 if (RESERVED_RLS_MEMBERSHIP_KEYS.includes(key)) continue; + 其上的论证注释 ✅ |
| 新增导出符号 | ^\+\s*export 零命中 ✅ |
packages/spec/** / 受治理面 |
一个文件都没碰 ✅ —— RESERVED_RLS_MEMBERSHIP_KEYS 是从 @objectstack/spec/contracts 消费的,⛔ 不是重新声明的 ✅ |
| 保留键成员 | rls-membership-resolver.ts:89-96 恰好 6 个:id / organization_id / positions / org_user_ids / accessible_org_ids / email ✅ |
⇒ 卡面「其余五个保留键未被触及」被证伪,实际是 6 of 6。 卡面那句写于 #16518 之前,本席派单时照抄了它、没有自己数 —— 你重数了,对。本席稍后会把这一条更正写到卡面上。
二、⛔ 本席派单里的推理错误,在此认下
派单第 2 步我写的是:
「
stageRlsMembership是不是context.rlsMembership的唯一生产者?若是唯一,形状 1 与形状 2 的覆盖面就等价,形状 1 更小」
这条蕴含是假的,而你用测量把它推翻了。 我在树上核了你那条承重发现,security-plugin.ts:6048,函数体第一行:
private async stageRlsMembership(context: any): Promise<void> {
if (!this.rlsMembershipResolver || !context || typeof context !== 'object') return;⇒ 没有注册 rls-membership-resolver 的部署里,stageRlsMembership 在第一行就返回了,种子袋不经任何筛查。所以「唯一生产者」为真,而「覆盖面等价」为假 —— 形状 1 在默认部署上覆盖 零。⭐ 这正是「先数再选」要防的事,只不过要数的不是谁写,是什么时候跑。派单把判据设窄了,错在本席。
⇒ 形状 2 成立,而且理由比卡面给的("compiler 是咽喉点")更硬:它不是更优雅,是唯一有效的那个。
三、Step 0 的裁定:hardening,⛔ 不升级
你给的零带了两个阳性对照,本席认这个零:rest-server.ts:2931 → assemble-execution-context.ts:353 确实把请求值(accept-language / ?locale=)抄进了 ExecutionContext.locale,证明你的搜索看得见这种形状;而 rlsMembership 被声明为 NonEntryExecutionContextField,整包 context 向量在 protocol.ts:10346 与 action-execution.ts:310 被无条件 delete。⇒ 不可达 ⇒ 不是活洞 ⇒ 本席不升级维护者,p2 维持。
⭐ 并且你把 objectui / cloud 声明为 UNKNOWN 而不是 "none" —— 对。够不到的树永远是未知。
四、⭐ 消融抓到的是你自己的夹具,不是修法 —— 这一条值得单独点名
id那格用了共享的{ userId: 'usr_a' }基座,而current_user.id就是userId—— 所以那是一个 kernel 值在场的格子,顶着"缺席"的名字。它以错误的理由通过了字节相等断言,只有旁边那条 fail-closed 断言把替身抓了出来。
⛔ 这就是"两个断言不是同一个断言"的现场教学:字节相等那条对一个伪装的输入照样为真。你没有把它当噪声,改成每格自带 absentCtx,然后真消融给出 14 红 / 18 绿、与写在先的预测逐格吻合,并用状态(blob 回到 f048998f、git diff HEAD 空、marker 计数回到 1)而不是退出码证明还原。⭐ 记在这里,是给下一个读这条 seam 的人看的。
五、共享 verify 锁的 exit 99:处置正确,⛔ 不立卡
你把三次 exit 99 记为 NOT MEASURED(⛔ 不是通过),然后带证据把消费者范围收窄(rlsMembership 只出现在 6 个包,6 个全跑,2103 + 457 全绿),其余交给 CI。本席去读了工具本身 —— scripts/pm/os-verify-lock.sh 把 exit 99 明写为一次诚实的拒绝(lock-unusable),其整段设计目标就是"⛔ 绝不产生静默自旋"。⇒ 你撞上的是设计内的行为,⛔ 不是缺陷,⛔ 不立卡。
issue-16737/sweep.sh(持有 44+ 分钟),而 #16737 正是本席同时派出的另一个席位。⇒ 同一 PM 的两个并发席会在这把锁上互相排队。⛔ 不是缺陷,是本席排并发时要计入的成本。
六、⛔ 本席替你改了 PR body 的一行:条款②的固定拼写
Check Changeset 在 head 上是红的,原因不是内容,是拼写。你写的是:
`Clause-②` holds at `no`.
而 scripts/pm/check-clause2-carriers.mjs 的 CLAUSE2_KEY_LINE 要求键后面紧跟一个冒号:
/^[ \t]*(?:>[ \t]*)?(?:[-*][ \t]+)?(?:\*\*)?`?Clause-②`?(?:\*\*)?[ \t]*:(.*)$/⇒ 没有冒号 ⇒ 整行根本不匹配,落进 near-miss ⇒ 判为"没有读数"。⛔ 这不是宽严问题,是字符类问题。冒号之后的论证是被接受的(readValueToken 只要求 token 是冒号后第一个东西,且恰为 yes / no)。
本席已把该行改为 `Clause-②`: no — … 并把你的四条理由原样留在冒号之后。Check Changeset 从 PR body 读,⛔ 无需 push、⛔ 无需 re-run,下一个 edited 事件即清。
⇒ 请记住这个固定拼写:Clause-②: yes|no,冒号紧跟键,token 紧跟冒号,理由写在 token 之后。
七、入队
CI 在改 body 时还有 17 个 check 在跑。⛔ 全绿之前不入队。⛔ 你不要自行 ready-for-review。
plugin-security 目前有三条线 —— 本 PR(rls-compiler.ts)、#17256(security-plugin.ts / permission-evaluator.ts / explain-engine.ts,绿着等同档位复核)、以及 #16778 那条无关的。落地次序由本席盯,后落地的一方并 main。
Generated by Claude Code
Fixes #17202
RLSCompiler.compileFiltermerged a caller-suppliedExecutionContext.rlsMembershipentry whenever
userCtx[key] === undefined. That is a test of whether the kernelhappened to resolve a value on this request — not of whether the key is reserved. So
on any request where the kernel resolved nothing, the bag won the name and supplied the
authorization vocabulary itself.
Step 0 (the hard gate) — is this reachable from a request path?
Measured answer: NOT reachable from any inbound face in this repo. This is a
hardening, not a live hole. Both halves of the measurement carry a positive control,
because a zero from a probe that cannot answer "yes" is NOT MEASURED, not "none".
rlsMembershipin any non-testsrc/ofrest/runtime/mcp/plugin-hono-server/client/console/cloud-connectiongit grep rlsMembership -- 'packages/*/src'packages/core/src/security/assemble-execution-context.ts:73,82rlsMembershipis declared aNonEntryExecutionContextField— "no request-identity resolution produces it", held by a closed type plus an exhaustiveness pincontextin the body)packages/metadata-protocol/src/protocol.ts:10346delete options.context— unconditionalpackages/runtime/src/action-execution.ts:310if (k === 'context') continue;ExecutionContextpackages/rest/src/rest-server.ts:2931requestLocale: this.extractLocale(req)(readsaccept-language/?locale=) landing atassemble-execution-context.ts:353locale:protocol.ts("a caller'scontextsurvived this spread and … became the operation's execution context")⛔
objectuiandcloudare not reachable from this container and are declaredUNKNOWN, not "none".
Unreachable today is not unreachable tomorrow — which is the reason to harden, not a
reason to downgrade the card.
Step 1 — the card's three cells re-run on today's
main(0780e8848)closed the third cell. It has not: #16518 fills
accessible_org_idsonly when the kernelresolves it, so with the kernel value absent the bag still wins. The gap is 6 of 6
reserved keys, not "the other five".
owner_email == current_user.emailrlsMembership: { email: [...] }{"owner_email":["victim@e.example"]}BAG WINSowner_email == current_user.emailemail: 'real@e.example'+ same bag{"owner_email":"real@e.example"}kernel winsemployer_org IN (current_user.accessible_org_ids)rlsMembership: { accessible_org_ids: [...] }{"employer_org":{"$in":["org_victim"]}}BAG WINSThe middle row is the negative control: with the kernel value present the rule already
worked, so rows 1 and 3 are not an artifact of the rig.
RESERVED_RLS_MEMBERSHIP_KEYSre-enumerated on today'smain— 6 members, one celleach, kernel value absent:
id{"owner_id":["usr_victim"]}organization_id{"org_col":["org_victim"]}positions{"role_col":{"$in":["admin"]}}org_user_ids{"id":{"$in":["usr_victim"]}}accessible_org_ids{"employer_org":{"$in":["org_victim"]}}email{"owner_email":["victim@e.example"]}The right-hand "bag absent" column is why the direction is widening: without the bag
every one of these fails closed to
RLS_DENY_FILTER(zero rows). The bag converted adenial into a satisfiable filter over caller-chosen values.
Step 2 — why shape 2, argued from the count
Writers of
context.rlsMembership: exactly one.git grep -E '\.rlsMembership\s*='over
packages/*/srcreturns a single line —security-plugin.ts:6394, insidestageRlsMembership. Positive control for the counting method: the same regex againstsibling context fields (
.accessible_org_ids =,.__readScope =) finds real writers inthree files across two packages, so the method is not blind to the shape.
But "one producer" does not make the two shapes equal, and the measurement says so.
stageRlsMembershipcannot carry this guarantee, for two independent reasons:rls-membership-resolveris registered(
if (!this.rlsMembershipResolver …) return;) — every deployment that has not optedinto the ADR-0105 D11 seam. The seed bag is then screened by nothing at all.
already-present
context.rlsMembershipwas never screened even when it does run.Measured end-to-end through the whole plugin, not asserted:
{"owner_email":["victim@e.example"]}{"owner_email":["victim@e.example"]}Row 1 is the case a
stageRlsMembership-only repair (shape 1) leaves completelyuntouched. Shape 2 it is: the compiler's merge is the one seam both faces pass through
— the read layer compiles
usingthere and the ADR-0058 D4 write gate compilescheckthere — so the refusal holds for every producer, including ones outside this repo.
Is the old behaviour legitimately used anywhere? No. Refusing by name costs
non-reserved keys nothing, and the three existing fixtures in the repo that stage a
reserved key through
rlsMembershipall have the kernel value present, so all three werealready exercising the kernel-wins path and are byte-unchanged.
The property, stated
Scope guards this repair stayed inside
⛔ No new parameter on
compileFilter(stillpolicies, executionContext, clause, fieldGuard— the #17115 / #17042 signature is untouched). ⛔ No new value in the WARNreasonvocabulary: a refused key leaves its variable unresolved, so the predicate takesthe existing unresolved-variable path, joins
deniedBy, and warns with the vocabularythat already exists. ⛔ No new exported symbol. ⛔ No
packages/spec/src/**edit —RESERVED_RLS_MEMBERSHIP_KEYSis consumed, not redeclared, so this merge andstageRlsMembership's screen can never disagree. ⛔ Noerror-code-ledgerrow.Clause-②: no — no new parameter oncompileFilter, no new WARNreasonvalue (a refused key takes the existing unresolved-variable path), no new exported symbol, nopackages/spec/src/**edit, noerror-code-ledgerrow.RESERVED_RLS_MEMBERSHIP_KEYSis CONSUMED from@objectstack/spec/contracts, not redeclared.This repair tightens the boundary. Nothing was relaxed to get a test green.
Tests
packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts— 32 cells:one per reserved key proving the two compilations are byte-identical
(
JSON.stringifyequality, nottoEqual), the same again on the write (check) face, afail-closed pin per key (deny sentinel — ⛔ not
null, ⛔ not{}, ⛔ not a throw), bothnegative controls, and the whole-plugin cells with and without a resolver. The cell table
is pinned against
RESERVED_RLS_MEMBERSHIP_KEYSitself, so a seventh reserved key redsthis suite instead of shipping unpinned.
Ablation — prediction written before mutating; mutate and measure in one shell under
trap … EXIT INT TERM:5296d5d4≠ HEAD blobf048998fgit checkout HEAD -- ABSOLUTE_PATH, proven by state: worktree blob backto
f048998f,git diff HEADempty for the file, absent fromgit status --porcelain,marker count back to 1
@objectstack/plugin-securityfull suite@objectstack/plugin-securitytypechecktsc --listFiles -p tsconfig.test.json→ 1 hit, exit 0)core,lint,plugin-audit,service-storage,spec(the bag-touching files)Declared narrowing on the cross-package run
The shared heavy-verify lock returned
exit 99(never acquired — NOT MEASURED) threetimes across 27 minutes; the holder was another session's sweep, held 44+ minutes. Rather
than stall, the consumer run was narrowed and the narrowing is declared here, with the
evidence for why it excludes nothing:
stageRlsMembership, and no package outsideplugin-securityregisters anrls-membership-resolver(every registration site inthe repo is a
plugin-securitytest);rlsMembershipappears in exactly six packages —spec,core,lint,plugin-audit,service-storage,plugin-security. All six were run;cli,rest,runtime,dogfood,verify,plugin-auth,plugin-dev,service-automation) never populate the bag, so no code path in them canobserve a change gated on a bag key being reserved. Their full suites are deferred to
CI, which runs the whole farm.
Gate reconciliation (honest)
dispatch-gates --ranon the final headc8b7c1ca1: 61 derived, 61 run, 0 UNRUN. Therunner's own zero is not the whole truth, so annotating it by hand:
pnpm check:dual-build-cjs-loads=PREREQUISITE NOT MET= NOT MEASURED,⛔ never a pass. It reads built output and four packages I never built have no
dist/(
studio,embedder-openai,knowledge-ragflow,service-cluster-redis) — none ofthem touched by this diff. CI builds everything, so it is measured there.
origin/main; the changedfamilies are
check-sdui-lockstep.mjs,engine-double-contract.pinned.json,publish-smoke.sh, none related to this diff). CI derives against the merge result.验收备注
Out of scope, noted and ⛔ not filed and ⛔ not fixed here:
explain-engine.ts:605documents that delegated (onBehalfOf) contexts leaverlsMembershipunresolved and calls the team/territory bags "a follow-up for theRlsMembershipResolvercontract". That is an existing, deliberately-recorded decisionin the safe direction (absent ⇒ narrows), not a defect this card touches.
stageRlsMembership's doc comment sentence "Reserved kernel keys can never beoverwritten" was aspirational at the plugin before this change and is now true at
the compiler. Left as written — the prose is now accurate.
🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Generated by Claude Code
Generated by Claude Code