Skip to content

security(rls): refuse RESERVED_RLS_MEMBERSHIP_KEYS by name at the RLS compiler merge - #17289

Draft
os-trump wants to merge 2 commits into
mainfrom
claude/issue-17202-reserved-rls-membership-keys
Draft

security(rls): refuse RESERVED_RLS_MEMBERSHIP_KEYS by name at the RLS compiler merge#17289
os-trump wants to merge 2 commits into
mainfrom
claude/issue-17202-reserved-rls-membership-keys

Conversation

@os-trump

@os-trump os-trump commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17202

RLSCompiler.compileFilter merged a caller-supplied ExecutionContext.rlsMembership
entry whenever userCtx[key] === undefined. That is a test of whether the kernel
happened 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".

what where verdict
rlsMembership in any non-test src/ of rest / runtime / mcp / plugin-hono-server / client / console / cloud-connection git grep rlsMembership -- 'packages/*/src' 0 hits
the shared transport assembly packages/core/src/security/assemble-execution-context.ts:73,82 rlsMembership is declared a NonEntryExecutionContextField — "no request-identity resolution produces it", held by a closed type plus an exhaustiveness pin
the wholesale-context vector (a caller putting context in the body) packages/metadata-protocol/src/protocol.ts:10346 delete options.context — unconditional
same vector, engine fallback path packages/runtime/src/action-execution.ts:310 if (k === 'context') continue;
positive control 1 — a request value that DOES reach ExecutionContext packages/rest/src/rest-server.ts:2931 requestLocale: this.extractLocale(req) (reads accept-language / ?locale=) landing at assemble-execution-context.ts:353 locale: the same grep method does see this shape
positive control 2 — the same shape where it once existed the pre-#3963 defect documented in-place at protocol.ts ("a caller's context survived this spread and … became the operation's execution context") the method sees a historical caller-to-context copy too

objectui and cloud are not reachable from this container and are declared
UNKNOWN, 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)

⚠️ The card's own prediction is falsified. The card expected #17200 (#16518) to have
closed the third cell. It has not: #16518 fills accessible_org_ids only when the kernel
resolves it, so with the kernel value absent the bag still wins. The gap is 6 of 6
reserved keys, not "the other five".

predicate context before after
owner_email == current_user.email rlsMembership: { email: [...] } {"owner_email":["victim@e.example"]} BAG WINS deny sentinel
owner_email == current_user.email email: 'real@e.example' + same bag {"owner_email":"real@e.example"} kernel wins unchanged
employer_org IN (current_user.accessible_org_ids) rlsMembership: { accessible_org_ids: [...] } {"employer_org":{"$in":["org_victim"]}} BAG WINS deny sentinel

The 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_KEYS re-enumerated on today's main6 members, one cell
each, kernel value absent:

key bag present (before) bag absent after
id {"owner_id":["usr_victim"]} deny sentinel byte-identical to bag-absent
organization_id {"org_col":["org_victim"]} deny sentinel byte-identical
positions {"role_col":{"$in":["admin"]}} deny sentinel byte-identical
org_user_ids {"id":{"$in":["usr_victim"]}} deny sentinel byte-identical
accessible_org_ids {"employer_org":{"$in":["org_victim"]}} deny sentinel byte-identical
email {"owner_email":["victim@e.example"]} deny sentinel byte-identical

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 a
denial 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/*/src returns a single line — security-plugin.ts:6394, inside
stageRlsMembership. Positive control for the counting method: the same regex against
sibling context fields (.accessible_org_ids =, .__readScope =) finds real writers in
three 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.
stageRlsMembership cannot carry this guarantee, for two independent reasons:

  1. It returns at its first line when no rls-membership-resolver is registered
    (if (!this.rlsMembershipResolver …) return;) — every deployment that has not opted
    into the ADR-0105 D11 seam. The seed bag is then screened by nothing at all.
  2. The bag it screens is the resolver's answer; the bag it seeds from an
    already-present context.rlsMembership was never screened even when it does run.

Measured end-to-end through the whole plugin, not asserted:

bag present bag absent identical?
no resolver registered (before) {"owner_email":["victim@e.example"]} deny sentinel no
resolver registered (before) {"owner_email":["victim@e.example"]} deny sentinel no
no resolver registered (after) deny sentinel deny sentinel yes
resolver registered (after) deny sentinel deny sentinel yes

Row 1 is the case a stageRlsMembership-only repair (shape 1) leaves completely
untouched
. Shape 2 it is: the compiler's merge is the one seam both faces pass through
— the read layer compiles using there and the ADR-0058 D4 write gate compiles check
there — 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 rlsMembership all have the kernel value present, so all three were
already exercising the kernel-wins path and are byte-unchanged.

The property, stated

A caller-supplied rlsMembership entry, whether or not the kernel value is present,
cannot change the authorization result any RESERVED_RLS_MEMBERSHIP_KEYS key decides.

Scope guards this repair stayed inside

⛔ No new parameter on compileFilter (still policies, executionContext, clause, fieldGuard — the #17115 / #17042 signature is untouched). ⛔ No new value in the WARN
reason vocabulary: a refused key leaves its variable unresolved, so the predicate takes
the existing unresolved-variable path, joins deniedBy, and warns with the vocabulary
that already exists. ⛔ No new exported symbol. ⛔ No packages/spec/src/** edit —
RESERVED_RLS_MEMBERSHIP_KEYS is consumed, not redeclared, so this merge and
stageRlsMembership's screen can never disagree. ⛔ No error-code-ledger row.
Clause-②: no — no new parameter on compileFilter, no new WARN reason value (a refused key takes the existing unresolved-variable path), no new exported symbol, no packages/spec/src/** edit, no error-code-ledger row. RESERVED_RLS_MEMBERSHIP_KEYS is 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.stringify equality, not toEqual), the same again on the write (check) face, a
fail-closed pin per key (deny sentinel — ⛔ not null, ⛔ not {}, ⛔ not a throw), both
negative controls, and the whole-plugin cells with and without a resolver. The cell table
is pinned against RESERVED_RLS_MEMBERSHIP_KEYS itself, so a seventh reserved key reds
this suite instead of shipping unpinned.

Ablation — prediction written before mutating; mutate and measure in one shell under
trap … EXIT INT TERM:

  • on-disk proof: marker count 1 → 0, worktree blob 5296d5d4 ≠ HEAD blob f048998f
  • predicted RED 14 (6 byte-identity + 6 write-face + 2 whole-plugin), predicted GREEN 18
  • measured: 14 failed | 18 passed — the prediction exactly
  • restored with git checkout HEAD -- ABSOLUTE_PATH, proven by state: worktree blob back
    to f048998f, git diff HEAD empty for the file, absent from git status --porcelain,
    marker count back to 1
suite result
@objectstack/plugin-security full suite 109 files / 2103 tests pass
@objectstack/plugin-security typecheck pass; test layer confirmed to compile the new file (tsc --listFiles -p tsconfig.test.json → 1 hit, exit 0)
cross-package: core, lint, plugin-audit, service-storage, spec (the bag-touching files) 457 tests pass

Declared narrowing on the cross-package run

The shared heavy-verify lock returned exit 99 (never acquired — NOT MEASURED) three
times 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:

  • the only way a bag gets filled is stageRlsMembership, and no package outside
    plugin-security registers an rls-membership-resolver
    (every registration site in
    the repo is a plugin-security test);
  • the token rlsMembership appears in exactly six packages — spec, core, lint,
    plugin-audit, service-storage, plugin-security. All six were run;
  • the dependent packages (cli, rest, runtime, dogfood, verify, plugin-auth,
    plugin-dev, service-automation) never populate the bag, so no code path in them can
    observe 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 --ran on the final head c8b7c1ca1: 61 derived, 61 run, 0 UNRUN. The
runner's own zero is not the whole truth, so annotating it by hand:

  • 60 exit 0
  • 1 exit 3 — 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 of
    them touched by this diff. CI builds everything, so it is measured there.
  • ⚠️ the derivation warns STALE TREE (this branch is behind origin/main; the changed
    families 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:605 documents that delegated (onBehalfOf) contexts leave
    rlsMembership unresolved and calls the team/territory bags "a follow-up for the
    RlsMembershipResolver contract". That is an existing, deliberately-recorded decision
    in the safe direction (absent ⇒ narrows), not a defect this card touches.
  • stageRlsMembership's doc comment sentence "Reserved kernel keys can never be
    overwritten" 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

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3cbcedb6291cc59a30dfda2961c8e526a775e1edpackageMentionDocs.

Which tree this was computed on

This run read content/docs from f542c29a5667650ad52ea9f0360eb0f6a5ee5e60 — the merge of head c8b7c1ca1c38cc0ec259b11dd7cd2afcf7abaa91 into base 3cbcedb6291cc59a30dfda2961c8e526a775e1ed, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

PM 复核(domain:services#6021):交付接收,⛔ 且本席派单里有一处推理是错的

一、在树上核对(⛔ 不采信报告自述)

head c8b7c1ca1,4 个文件:changeset + 一个既有测试改 10 行 + 生产代码只有 rls-compiler.ts 一处 + 321 行新测试。

断言 本席量到的
生产改动 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:2931assemble-execution-context.ts:353 确实把请求值(accept-language / ?locale=)抄进了 ExecutionContext.locale,证明你的搜索看得见这种形状;而 rlsMembership 被声明为 NonEntryExecutionContextField,整包 context 向量在 protocol.ts:10346action-execution.ts:310 被无条件 delete。⇒ 不可达 ⇒ 不是活洞 ⇒ 本席不升级维护者,p2 维持。

⭐ 并且你把 objectui / cloud 声明为 UNKNOWN 而不是 "none" —— 对。够不到的树永远是未知。

四、⭐ 消融抓到的是你自己的夹具,不是修法 —— 这一条值得单独点名

id 那格用了共享的 { userId: 'usr_a' } 基座,而 current_user.id 就是 userId —— 所以那是一个 kernel 值在场的格子,顶着"缺席"的名字。它以错误的理由通过了字节相等断言,只有旁边那条 fail-closed 断言把替身抓了出来。

⛔ 这就是"两个断言不是同一个断言"的现场教学:字节相等那条对一个伪装的输入照样为真。你没有把它当噪声,改成每格自带 absentCtx,然后真消融给出 14 红 / 18 绿、与写在先的预测逐格吻合,并用状态(blob 回到 f048998fgit 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.mjsCLAUSE2_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)、#17256security-plugin.ts / permission-evaluator.ts / explain-engine.ts,绿着等同档位复核)、以及 #16778 那条无关的。落地次序由本席盯,后落地的一方并 main。


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants