fix(auth): 两个 membership 入口对词表外的 policy 给出同向的拒绝 (#5205) - #5303
Merged
Conversation
`reconcileMembership` 与 `backfillMemberships` 读同一个 policy 字段,却用 相反的谓词判断它:注册路径测 `=== 'invite-only'`,于是任何其他值(包括 拼错的 `'inviteOnly'`)落到 `auto` 分支照常自动绑定 —— fail-open;回填路径 测 `!== 'auto'`,不绑 —— fail-safe。同一个输入,两个相反的姿态,而危险的 那半正好在逐个 sign-up 的路径上:调用方以为自己关掉了自动绑定,实际没有, 日志里也看不出来。 两处入口现在都在任何策略语义之前调 `isMembershipPolicy()` 判非法,返回独立的 `'invalid-policy'` outcome/reason,并在 `error` 级别(以及返回值上,好让没传 logger 的调用方也拿得到)说明是哪个值非法。不复用 `policy-skip` 是刻意的: 那个名字意思是「一个合法的策略说了不」,拿它报告「你这个值不是策略」,会把 排障的人指向一个其实没问题的部署设置。这与 #5152 在 settings 边界上的姿态 同源 —— 非法值响亮拒绝,绝不静默强转成 `auto`。 契约变更:导出的 `ReconcileOutcome` 与 `BackfillMembershipsResult['reason']` 各增一个成员,`BackfillMembershipsResult` 增可选 `error?`, `ReconcileMembershipDeps['logger']` 增可选 `error?` 方法(缺省回落到 `warn`)。 `auto` / `invite-only` 两条路径的行为逐条不变,并已由表驱动测试钉住。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5205
问题
packages/plugins/plugin-auth/src/reconcile-membership.ts里,同一个policy字段被两个函数用相反的谓词判断:传入词表外的值(拼错的
'inviteOnly'、undefined、一个对象……)时:reconcileMembership(注册路径,逐个绑定)auto分支,照常自动绑定 —— fail-openbackfillMemberships(回填路径,批量)auto,不绑 —— fail-safe危险的是前者:调用方以为自己关掉了自动绑定,实际上没有,日志里也看不出来。
#5152 之后框架自身通路上不可能再出现非法值(两个调用点都走
AuthManager.getMembershipPolicy(),返回类型是MembershipPolicy),但index.ts有export * from './reconcile-membership.js'—— JS 侧调用方或 cloud 宿主可以绕过类型直接传。所以这是「休眠」而不是「不可达」。为什么是形状 2,不是形状 1
维护者 2026-08-04 在 #5205 上裁定进 v17、取形状 2。
形状 1(把
reconcileMembership一行收紧成!== 'auto')确实能消灭方向性错误,且改动最小。但 issue 自己写了它的代价:policy-skip这个 outcome 名对「值非法」这个原因是撒谎。它的语义是「一个合法的策略说了不」,拿它报告「你传的这个值根本不是策略」,会把排障的人指向部署设置去看 —— 而那个设置好端端的。少写的那几行,日后要在一次线上排障里连本带利还回来。形状 2(两处入口用
isMembershipPolicy()显式判非法 + 独立的'invalid-policy'+ 记error)与 #5152 在 settings 边界上的姿态同源:非法值响亮拒绝,不静默强转。#5152 的auth-plugin.ts对一个词表外的OS_AUTH_MEMBERSHIP_POLICY就是这么做的(logger.error+ 忽略 + 报出允许值),本 PR 只是把同一条纪律推到它下游的两个函数里,让这一层不再有第二种姿态。代价是它改导出的联合类型 —— 正因如此才搭 v17 这班主版本车。
契约变更的影响面
ReconcileOutcome'invalid-policy'BackfillMembershipsResult['reason']'invalid-policy'BackfillMembershipsResulterror?: stringreconcileMembership()返回值error?: stringReconcileMembershipDeps['logger']error?(缺省回落到warn)对下游是 minor(changeset 按 minor 记)。唯一会被顶到的形状,是对这两个联合类型做穷尽 switch 的消费者(
never收尾的default,或Record< ReconcileOutcome, T >之类的全映射)—— 那种消费者需要补上新成员。仓内消费者已逐个查过,均不受影响:
auth-manager.ts:3728忽略返回值(best-effort hook);admin-user-endpoints.ts:308只读result.outcome === 'bound'与result.organizationId;auth-plugin.ts:887只读res.bound;packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts只对合法值断言'policy-skip'/'policy',语义未动。error记的是哪个值非法,且不吞原值 —— 同时进日志和返回值(后者是为了让没传 logger 的调用方也拿得到,拒绝本身不是日志的副作用)。策略是封闭词表、值很短,所以字符串原样回显('inviteOnly'与'invite-only'的区别正是这条消息的全部意义),并在 64 字符处截断;非字符串只报typeof([object]),因为那是脱离类型契约到达的任意调用方数据,日志行不是发现它带着用户字段的地方。对抗性验证
新测试对未修复的源码(
origin/main的reconcile-membership.ts,测试文件为本 PR 版本)真实执行:expected 'bound' to be 'invalid-policy'与to have a length of +0 but got 1是今天的 fail-open 被抓现行:一行sys_member真的写进去了。同一次运行里 19 passed —— 既有测试与新加的「合法值行为不变」表全绿,说明红的只有本 PR 要修的那条方向性错误。修复后同一文件:
测试
reconcileMembership拒绝 /backfillMemberships拒绝 / 两者同向且都不绑),并显式断言outcome !== 'policy-skip'、reason !== 'policy';resolveTargetOrg一次都没被调用;error级而非warn;只有warn的 logger 也收得到;完全不传 logger 时照样拒绝;[object],断言ops@example.com不出现在消息与 meta 里);超长字符串截断;skipped/engine-unavailable,不是invalid-policy(两个函数的检查顺序保持对称);MEMBERSHIP_POLICIES表驱动,钉住auto→bound/ 绑 1 行、invite-only→policy-skip/reason: 'policy'/ 绑 0 行,外加一条断言保证这张表覆盖了整个声明词表(以后往词表里加值,这条会先红)。证据边界
pnpm --filter @objectstack/plugin-auth全量:Test Files 34 passed (34)/Tests 780 passed (780);tsc --noEmit退出 0。check:engine-double-contract、check:adr-anchors、check:doc-authoring、check:org-identifier、check:role-word、check:startup-registry-verdict全 PASS;改动两文件 eslint 退出 0。@objectstack/dogfood的tsc --noEmit:membership-reconciler.dogfood.test.ts对@objectstack/plugin-auth的 import 无报错(该文件唯一的错是第 25 行@objectstack/verify的TS2307)。该包剩余 181 条错全部是TS2307 Cannot find module,指向本会话未构建、且本任务禁止触碰的兄弟包(@objectstack/verify、plugin-security、plugin-audit…,organization/create 的闸门判「请求的 posture」还是「实际生效的 posture」?降级部署(D5)下两者分叉,闸门放行而 /auth/config 隐藏 #5261 正在改),属沙箱构建状态,与本改动无关。未跑 dogfood 的运行时测试(要真实 DB)。文件面
只碰了
packages/plugins/plugin-auth/src/reconcile-membership.ts及其同名测试,加一个 changeset。ReconcileOutcome的定义与导出都在这一个文件里(index.ts经export * from './reconcile-membership.js'转出,未改)。未触碰auth-manager.ts、org-create-posture-gate.test.ts、packages/objectql、plugin-dev、runtime、driver-sql、cli、packages/types/src/env.ts、packages/qa/**、packages/verify/**、content/docs/releases/。🤖 Generated with Claude Code
https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
Generated by Claude Code