fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies - #14129

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies
Sep 1, 2026
Merged

fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies#14129
os-steve merged 2 commits into
mainfrom
claude/issue-14029-no-bypass-managed-denies

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14029

Clause-②: yes — content limb. Bringing a permission set under managed-write-deny injection changes accept/reject behaviour: organization_admin_no_bypass will be refused generic create/edit/delete on any future registry-declared managedBy: 'better-auth' object that the static baseline misses, writes its wildcard grants today. Surface touched: MANAGED_DENY_TARGET_SETS in packages/plugins/plugin-security/src/managed-object-write-denies.ts (the kernel:ready injection target list) plus the membership pins. No spec/Zod schema, API shape, or wire format changes; on today's tree the seeded permission sets are byte-identical (the static BETTER_AUTH_MANAGED_OBJECTS baseline already covers all 28 declared managed tables, and the variant carries it via the derivation copy).

The gap

MANAGED_DENY_TARGET_SETS named four sets and applyManagedWriteDenies matches exactly, so at kernel:ready the injection walked the derived organization_admin_no_bypass variant and skipped it. The variant is a shallow copy of organization_admin taken at module load (deriveWallLessOrgAdmin strips only the viewAllRecords/modifyAllRecords superuser bits), which means (a) its '*' wildcard still grants create/edit/delete, and (b) entries injected into the parent's objects can never propagate to it. deriveWallLessOrgAdmin's own docblock declares "managed-write denies … carried over verbatim" and "the only intended delta is the superuser bits" — declared-vs-enforced, so the implementation is what gets fixed.

Premise verification (the card was a source reading)

Measured by executing the real modules (temporary vitest probe, 4/4 green pre-fix, then removed):

  • Walked and skipped, not absent: the variant sits directly after its parent in defaultPermissionSets (the array bootstrapPermissionSets defaults to and runBootstrap hands to applyManagedWriteDenies at the kernel:ready hook, security-plugin.ts line 3612). Applying a fake managedBy: 'better-auth' schema outside the static list yielded applied: 4 — parent injected, variant untouched.
  • Wildcard still write-granting: the variant's objects['*'] carries allowCreate/allowEdit/allowDelete all true. One immaterial delta from the card's wording: after PermissionSetSchema.parse the stripped superuser bits are present-as-false (Zod boolean defaults), not absent — the effect is identical.
  • Shallow-copy isolation: mutating the parent's objects after derivation does not reach the variant.

Both halves

  1. ORGANIZATION_ADMIN_NO_BYPASS is now a member of MANAGED_DENY_TARGET_SETS. Docblocks updated on the module, the list, and deriveWallLessOrgAdmin (the derivation now states that the registry union reaches the variant by direct membership, not by the copy).
  2. The pin no longer checks the list against itself.default-permission-sets.test.ts derives the required floor from an independent property — default sets whose objects['*'] wildcard grants any generic write class, read from the real seeded defaultPermissionSets — and diffs it against MANAGED_DENY_TARGET_SETS; a non-empty difference is red. The one documented exclusion (admin_full_access, admin rescue path) is pinned exactly, like EDIT_EXCEPTIONS. A liveness guard pins the property derivation itself so an empty probe cannot pass vacuously.

Verification (all at 7e63b8ddc, clean tree)

  • Positive (the card): a managedBy: 'better-auth' object outside the static baseline now reaches the variant — new test injects sys_future_identity_table into a structuredClone of the real seeded sets and asserts the deny entry lands on organization_admin_no_bypass (and still on the parent).
  • Mutation contrast (predicted directions first, then measured): with the fix committed, ORGANIZATION_ADMIN_NO_BYPASS was removed from the list on disk (marker count 1 to 0, blob 4d06d8f… to 16abaa7…, both against the HEAD blob). Under that mutation: the OLD self-referential pin ("each write-granting target set denies create/edit/delete on every managed object") stayed green — the blind spot — while the NEW independent-property pin went red naming exactly ['organization_admin_no_bypass']; the positive test, reverse control, and both exact-membership tests also went red (5 failed / 26 passed, every direction as predicted). Restore proven by state: blob hash equal to the HEAD blob, git diff HEAD empty, marker count 1. The mutation reaches the run via in-package relative imports under vitest (source resolution — no dist leg to rebuild for these suites).
  • Reverse control: with the full static registry plus one future table applied, every entry the variant already names (all 28 baseline entries, the '*' wildcard, the sys_position RBAC read-only block) survives unchanged; only the future table is added.
  • Controls: admin_full_access untouched by the injection (deep-equal before/after) and still outside the list (pinned); the four existing target sets keep their existing pins unchanged and green — the fix adds a fifth target, it does not move the four.
  • Suites: @objectstack/plugin-security full suite 94 files / 1770 tests green; typecheck (all three tsc programs) green; tsc --listFiles shows all four edited files inside the compiled programs (sources in main+test, tests in the test program).
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args), both sections read whole — 36 harvested families run locally, 35 exit 0 (including check:type-check-debt re-measure, check:i18n, check:engine-double-contract, check:where-matcher); scripts/check-test-completeness.mjs exited 3 = PREREQUISITE NOT MET = NOT MEASURED (it parses CI shard summaries that do not exist locally; CI's Test Core shards own it). Full-repo pnpm lint (eslint, no-inline-config) exit 0. All exit codes captured before any pipe.

Release note

.changeset/no-bypass-managed-deny-target.md — patch on @objectstack/plugin-security.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ry-driven managed-write denies (#14029)
The derived wall-less org-admin variant holds a write-granting '*' wildcard
but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it
and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a
shallow copy at module load, injections into the parent could never propagate
either. Add the variant to the target list, and replace the self-referential
membership pin with one that derives the required floor (write-granting
wildcard sets) from the real seeded sets and diffs it against the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 1, 2026

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. ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 14 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 bf3bbf13b9be4f3996726a239cfe5642395c2396packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2de742f7544ae107161ddca367ff9636ee14e50 — the merge of head 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 into base bf3bbf13b9be4f3996726a239cfe5642395c2396, 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 b2de742f7544ae107161ddca367ff9636ee14e50 && git checkout b2de742f7544ae107161ddca367ff9636ee14e50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf3bbf13b9be4f3996726a239cfe5642395c2396 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9 && git checkout -B drift-repro bf3bbf13b9be4f3996726a239cfe5642395c2396 && git merge --no-ff 8d5cca2a8908c8154efa4abf19f2c18a50baa9a9
node scripts/docs-audit/affected-docs.mjs --json bf3bbf13b9be4f3996726a239cfe5642395c2396

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:FAIL —— 逐字采纳,标签保留,返工已派在契约复审档位

转录核验(采信前必做):

assistant 轮次: 77
盖章 "claude-fable-5": 77
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 裁决合法,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:FAIL

结论一行

增补与对照均实;楼层滤器漏 modifyAllRecords 型通配集,补一行判定即清。

阻塞项

  1. 楼层滤器对"授写通配"的判定窄于评估器的真实写语义,新 pin 宣称的保证对超管位型通配集不成立。
    新 pin 的楼层推导只认三个 CRUD 写位:wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true(packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts:387-390)。但评估器授写不止这一条路:packages/plugins/plugin-security/src/permission-evaluator.ts:224if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) return true;,而 MODIFY_ALL_WRITE_KEYS 覆盖 allowEditallowDelete 及整个 destructive 类(同文件 :60-67)。于是一个未来缺省集若写成 '*': { allowRead: true, modifyAllRecords: true }(数据管家式形态;且这是超管通配,连 private 对象都被它覆盖,ADR-0066 D2),它在评估器意义上对每张未来身份表持有 edit/delete,却三位全 false、逃出楼层,不触任何 pin —— 与 managed-object-write-denies.ts:83-89 明文承诺的"a future write-granting set that is not added here fails a pin instead of silently keeping its wildcard"直接矛盾。这正是本卡要消灭的"应入列者可藏身"缺陷类,只是形态更小;修补入列后注入是有效的(逐对象条目整体覆盖通配、且不带 modifyAllRecords 键,:224 读到 falsy),缺的只是 pin 没有要求它入列。
    清除该项的具体改法:default-permission-sets.test.ts:390 的过滤条件补 || wc.modifyAllRecords === true(Zod 已把该位物化为 present-as-false,packages/spec/src/security/permission.zod.ts:268.default(false),故值判定安全无脆性);同时把 managed-object-write-denies.ts:83-89 docblock 的"grants create/edit/delete"对齐为"经由三写位modifyAllRecords 授出任一通用写类"。现树上该改动零行为差(现有超管位集 organization_admin / admin_full_access 三写位同为 true,楼层集合不变)。

非阻塞

  1. Changeset 数字失实:静态基线是 28 张表,不是 30。BETTER_AUTH_MANAGED_OBJECTS 28 项(default-permission-sets.ts:58-91),与 platform-objects 中声明 managedBy: 'better-auth' 的 28 个 schema 名单 diff 逐名相同(sys-oauth-resource-sourced-bounds.test.ts 为测试文件不计);全树 platform-objects 之外无任何此类声明(仅注释/比较)。"覆盖全部已声明管理表、今日注入 inert"的实质成立,BETTER_AUTH_MANAGED_OBJECTS deny-list has already drifted (17/22) — derive managed-object write denies from the registry (ADR-0092/0103 follow-through) #3325 双向 pin 也在强制这一等式(default-permission-sets.test.ts:28-47),错的只是 changeset 散文里的计数。发布前顺手把 "30" 改 "28" 即可,不改判定。

我核了什么 / 我没核什么

核了(源码与 diff 逐行):

  • Q1(今日无行为变化):父集在解析时铺入全部 28 条静态 deny(default-permission-sets.ts:210),deriveWallLessOrgAdmin 浅拷贝 base.objects 携带全部逐对象条目(:1052-1069);applyManagedWriteDenies 对已有键只计 skip(managed-object-write-denies.ts:155-157);注册表侧全树无 platform-objects 之外的 better-auth 声明 → 今日对 variant applied=0,种子集字节不动,变更只在未来表上生效。运行时确实走到 variant:manifest.ts:37securityDefaultPermissionSets = defaultPermissionSets(含派生插入,:1071-1082)→ security-plugin.ts:1005-1006:3318 全量传入。
  • Q2(admin_full_access 排除):通配全位真、无逐对象条目(spec eval-user.zod.ts:83-127 铺入 default-permission-sets.ts:132-145;裸通配 pin 在 default-permission-sets.test.ts:100);不在五名目标表内(managed-object-write-denies.ts:107-113);排除表精确 pin 且与目标表互斥双向断言(test :403、:421-422)。硬编码排除复现自指问题:它是独立的测试侧记录,被 diff 测试消费而非复述被测表;拓宽需在同一受审文件同时改 :403 与 :421,若有人把它塞进目标表,:422 变红——与 EDIT_EXCEPTIONS(:61)同一纪律。
  • Q3(pin 独立性):推导只读 defaultPermissionSets(:387),MANAGED_DENY_TARGET_SETS 仅以被检对象身份出现在 diff 断言(:414-415);liveness 守卫(:408-410)要求滤出两个 org-admin 名并且 ≥3,滤器静默为空必红,守卫是活的。楼层概念(必要条件、成员资格可更宽,docblock 与实现一致:viewer_readonly / member_default 是超楼层成员)判定正确;实现窄于评估器写语义 → 阻塞项 1。
  • Q4(反向对照):三个用例都克隆真实派生集(:433/:444/:464,模块导入即运行时所用同一数组);structuredClone 只为隔离其他用例读的模块级实例,被测函数在克隆上真实执行原地变异,前后快照比对非空洞;variant.objects[FUTURE] 注入前 toBeUndefined 证明探针表确不在基线;28 条基线条目、'*'sys_position 逐一比对,键集恰增一。断言的是 28 条,不是简报里说的 30。
  • 前提增量(present-as-false):属实 — permission.zod.ts:246/:268 两个超管位均 .default(false),deriveWallLessOrgAdmin 析构剔除后经 parse 以显式 false 回到 variant 通配上。对修复无影响(注入判 name in objects,不读位);对楼层滤器无影响且恰好是作者选对了值判定(=== true)而非存在性判定——若按键存在性写滤器,present-as-false 会导致误判。一处形态小divergence:注入条目是未过 parse 的四键对象,种子条目是物化过默认值的宽对象,评估器读 falsy 语义等价,不构成缺陷。

没核(NOT MEASURED,由谁测):

  • 两个测试文件的实际执行:未跑 vitest(共享 verify 锁整会话被占,遵嘱以源码推理为准)——NOT MEASURED,CI 度量;开发者自报 94 文件 / 1770 用例绿,未采信为绿,仅记录为报告。
  • kernel:ready 真机注入计数与种子行字节比对:NOT MEASURED,由 CI 集成层与 dogfood 流程度量;我核的是 security-plugin.ts:3316-3330 调用链与引用共享契约的源码文本。

PM 侧处置

⭐ 这条阻塞项值得单独记一笔

它是本卡要消灭的那个缺陷类的更小一号形态,而且只有读了评估器的写语义才看得见 —— 只看通配上的三个 CRUD 位是看不出来的。复审席自己把这层说穿了:新 docblock 承诺「a future write-granting set that is not added here fails a pin」,而按当前滤器,一个 '*': { allowRead: true, modifyAllRecords: true } 的集合恰好不会红。⇒ 承诺与实现不一致,正是本车道今晚反复在修的同一形状。

⚠️ 顺带确认复审席的另一处判断:作者用值判定(=== true)而不是存在性判定是对的 —— Zod 把超管位 .default(false) 物化成 present-as-false,若按键存在性写滤器,今天就会误判。这一点在返工里 ⛔ 不要动。


Generated by Claude Code

…write route (contract-review blocking item)
The independent-property floor in default-permission-sets.test.ts recognised
only the three CRUD flags, but the evaluator grants writes by a second route:
MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224)
covers allowEdit, allowDelete and the destructive class. A future default set
shaped '*': { allowRead: true, modifyAllRecords: true } therefore held
edit/delete on every future identity table in the evaluator's own terms, yet
had all three CRUD flags false, escaped the floor and tripped no pin —
contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a
write-granting set not listed there fails a pin. Same "a set that should be a
member can hide" class this card exists to kill, one size smaller.
- default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to
the floor filter. Value test (`=== true`) kept deliberately: Zod
materialises both superuser bits with .default(false)
(permission.zod.ts:268), so they are present-as-false and a key-existence
test would misfire today.
- managed-object-write-denies.ts: align the docblock — the floor is "grants
any generic write class via the three write flags OR modifyAllRecords",
not "grants create/edit/delete".
- .changeset: the static baseline covers 28 managed tables
(BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item).
Zero behaviour delta on today's tree: organization_admin and
admin_full_access carry all three CRUD flags true, so the derived floor set
is unchanged. Measured: with a temporary '*': { allowRead: true,
modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the
widened pin goes red naming exactly the probe; the old three-flag filter
stays green on the same probe (the blind spot). Probe removed; restore
proven by blob hash against HEAD. plugin-security suite 94 files / 1770
tests green; all three tsc programs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Rework for the contract-review FAIL — blocking item 1 + non-blocking 1, at 8d5cca2a8

What changed

  1. Blocking item 1 — the floor now sees the evaluator's second write route.default-permission-sets.test.ts floor filter gains || wc.modifyAllRecords === true: the evaluator grants writes not only via the three CRUD flags but via MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224, covering allowEdit, allowDelete and the destructive class), so a future default set shaped '*': { allowRead: true, modifyAllRecords: true } is write-granting in the evaluator's own terms while all three CRUD flags are false. A comment at the filter records both the second route and why the clause is a value test: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today — === true kept exactly as the review prescribed.
  2. Docblock aligned (managed-object-write-denies.ts): the floor now reads "grants any generic write class — via the three write flags OR via modifyAllRecords", naming the evaluator route, instead of "grants create/edit/delete". The promise "a future write-granting set that is not added here fails a pin" is now true for the superuser-bit shape too.
  3. Non-blocking 1 — changeset count corrected 30 → 28 (BETTER_AUTH_MANAGED_OBJECTS has 28 entries; re-counted mechanically). The PR body repeated the number in two places ("all 30 declared managed tables", "all 30 baseline entries"); both edited to 28.

Zero behaviour delta on today's tree, as the review predicted: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged.

Measured, not asserted (all exit codes captured before any pipe)

  • The widened clause detects the shape it was added for. Temporary probe set sys_probe_modify_all_only with '*': { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } appended to defaultPermissionSets, absent from MANAGED_DENY_TARGET_SETS — mutation confirmed on disk (marker count 0→1, blob a700501…39b1562… vs HEAD blob). With the widened filter: exit 1, exactly one test red — "every write-granting wildcard set is a managed-deny target or a documented exclusion", expected [ 'sys_probe_modify_all_only' ] to deeply equal []. Contrast leg: same probe, test file reverted to the old three-flag filter (blob = HEAD blob) — exit 0, 22/22 green, the blind spot the FAIL named. Restore proven by state: probe file blob back to HEAD's a700501…, marker count 0, probe name absent from the tree, git status shows only the three intended files; post-restore run of the pin file exit 0, 22/22.
  • Suite: pnpm --filter @objectstack/plugin-security test exit 0 — 94 files / 1770 tests, same totals as pre-rework (no test was added or removed; one clause and comments changed).
  • Typecheck: all three tsc programs exit 0.
  • Gates: re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read whole — same 5 paths, same 30 families as the original dispatch (family list byte-identical modulo staleness counters, re-derived again after git fetch origin main). Changeset-facing families re-run after the prose edit: check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-adr-0087-registration all exit 0. Declared narrowing: the derivation warns the branch tree is behind origin/main with 2 gate-source files changed across the range (.github/workflows/lint.yml, scripts/pm/check-half-states.mjs), so the local family list reads their old copies; the full farm and check-test-completeness (exit 3 locally = NOT MEASURED, as before) remain CI's to measure on the merge tree.

Deliberately left alone

  • Everything the review passed: the membership addition, the admin_full_access exclusion and its exact pinning, the reverse control, the Q1 "inert today" analysis — untouched.
  • No modifyAllRecords-shaped set was added and no permission set's actual grants changed: this is a pin-widening plus a docblock correction, nothing more.
  • The === true value tests — not converted to key-existence, per the review's explicit confirmation.

PR stays draft, needs:contract-review stays on — landing and label clearance are the PM's after re-review.


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审(返工复核):PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 40
盖章 "claude-fable-5": 40
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审(返工复核):PASS

结论一行

阻塞项与计数均实清,=== true 值判定保留,复审范围外零扰动。

非阻塞

  1. 同类残余、再小一号:allowTransfer 型通配仍逃出楼层(留档开卡,不阻本次)。 评估器的第一条路是 OPERATION_TO_PERMISSION[operation] 直读位(permission-evaluator.ts:23-32,transfer: 'allowTransfer'),而 allowTransfer 是真实可授、已强制的位(permission.zod.ts:193,.default(false),安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 ENFORCED)。未来若有缺省集写成 '*': { allowRead: true, allowTransfer: true },它对每张未来身份表持有 transfer(改属主)却三写位与 modifyAllRecords 全 false,两条楼层判定都不触。本次 docblock 以穷举定义楼层("via the three write flags OR via modifyAllRecords",managed-object-write-denies.ts:84-93),承诺在自身定义内为真,不构成上轮那种承诺-实现矛盾;且首轮裁决只处方了 modifyAllRecords 一句,此项不归返工背。清法(若开卡):滤器再补 || wc.allowTransfer === true(同为值判定,Zod 已物化 present-as-false),docblock 穷举同步;现树零行为差(无 transfer-only 缺省集)。

我核了什么 / 我没核什么

核了(全部源码与 diff 逐行,git show 8d5cca2a8,未动共享检出、未取 verify 锁):

  • R1 — 阻塞项实清:7e63b8ddc → 8d5cca2a8 全 diff 仅 3 文件(changeset、managed-object-write-denies.tsdefault-permission-sets.test.ts)。楼层滤器(test :394-403)现为 wc.allowCreate === true || wc.allowEdit === true || wc.allowDelete === true || wc.modifyAllRecords === true —— 新增子句保留了 === true 值判定,未退化为存在性判定(⭐ 逐字核过);滤器上方注释(:385-392)把第二条授写路(MODIFY_ALL_WRITE_KEYS,permission-evaluator.ts:224)和 present-as-false 依据(permission.zod.ts:268.default(false),本轮重核属实)都记了档。
  • R2 — docblock 现为真:managed-object-write-denies.ts:83-93 的楼层改为穷举式("三写位 ORmodifyAllRecords",点名 MODIFY_ALL_WRITE_KEYS / evaluator 路由),与 pin 实际计算逐项一致;"grants edit/delete and the destructive class" 与 permission-evaluator.ts:60-67(allowEdit+allowDelete+映射 destructive=allowTransfer)相符。上轮矛盾句 "fails a pin instead of silently keeping its wildcard"(:93)在 '*': {allowRead, modifyAllRecords} 形态上现在成立。
  • R3 — 计数:BETTER_AUTH_MANAGED_OBJECTS 机械计数 28(default-permission-sets.ts:58-91,grep -c = 28);changeset 现写 28 且全文 grep "30" 零命中;PR body 现有两处 "28"("all 28 declared managed tables"、"all 28 baseline entries"),全文无 "30" 残留(返工评论里的 "30 families" 是 gate 家族数,非管理表数,不在此列)。
  • R4 — 零扰动:managed-object-write-denies.ts 的 diff 全部落在块注释行(* 前缀),MANAGED_DENY_TARGET_SETSMANAGED_DENY_ENTRYapplyManagedWriteDenies 字节未动;default-permission-sets.ts 不在 diff 内 ⇒ 无任何权限集实际授权变化;首轮已过的成员增补、admin_full_access 排除及其精确 pin(test :416:433)、反向对照、行为用例逐一比对未动。确系"拓 pin + 正 docblock",无夹带。
  • 对照腿主张的代码一致性:探针形态 {allowRead:true, modifyAllRecords:true} 被拓宽滤器选中、被旧三位滤器放过,diff 断言(test :426-431)非空且恰名探针 ⇒ 恰一红;该文件 20 个 it + it.each×2 = 22 用例,与自报 "22/22" 恰合。主张与代码如写一致。
  • STALE TREE 主张:实测 merge-base 09e4b0ece,origin/main 已到 e4916fc4b,范围 62 文件,恰含自报的 .github/workflows/lint.ymlscripts/pm/check-half-states.mjs;packages/plugins/plugin-security文件、packages/spec/src/security 下零文件 ⇒ 不触契约增量,不阻塞,CI 在 merge 树上自会以新 gate 副本重测。

没核(NOT MEASURED,由谁测):

  • 两条对照腿与 94 文件 / 1770 用例的实际执行:未跑 vitest(遵嘱避开共享 verify 锁)——代码推理一致 ≠ 绿,NOT MEASURED,CI 度量;自报数字仅记录为报告,不采信为绿。
  • gate 家族本地重跑与 check-test-completeness(自报 exit 3 = 前提未备):NOT MEASURED,CI 的 Test Core 分片与 merge-tree gate 度量。

PM 侧收口

⭐ 非阻塞项已立卡,⛔ 不夹带进本 PR

复审席的裁量很准,本席原样采纳:本轮 docblock 把楼层改成了穷举式定义("三写位 ORmodifyAllRecords"),所以它在自身定义内为真 —— 不再是上一轮那种「承诺 fails a pin、实现却放过」的矛盾;而首轮裁决只处方了 modifyAllRecords 一句,返工不背这个。

⇒ 但残余是真的,本席已独立复核:permission-evaluator.ts:31 确有 transfer: 'allowTransfer'第一条直读位路径(反向对照:同形 grep 在同文件命中 update: 'allowEdit' ⇒ grep 是活的)。一个 '*': { allowRead: true, allowTransfer: true } 的未来缺省集,对每张未来身份表持有改属主权,而两条楼层判定都不触。

⇒ 已另立卡承载,⛔ 不塞进这个 PR。这是同一缺陷类的第三次缩小:先是「集合不在表里」,再是「楼层漏 modifyAllRecords」,现在是「楼层漏 allowTransfer」。⭐ 每一层都只有读评估器的授权语义才看得见,读通配上的位是看不见的。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 07:06
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 20b79beSep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-14029-no-bypass-managed-denies branch September 1, 2026 07:28
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…grant route — allowTransfer (#14137)
The independent-property floor (#14029) read the three CRUD write flags and
modifyAllRecords, missing the evaluator's FIRST grant route: the direct bit
read off OPERATION_TO_PERMISSION (transfer: 'allowTransfer'), ENFORCED today
through the insert/update owner_id door (#3004). A transfer-only wildcard
default set would never have been required to be a managed-deny target.
Extend the floor with a value test (=== true, never key-existence — the bits
are present-as-false via .default(false), #14129 first review), sync both
exhaustive docblocks to name the route, and pin: positive transfer-only
shape, read-only reverse control, dedicated present-as-false pin, and a
zero-delta invariance sweep over the seeded sets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude