Skip to content

fix(auth): 两个 membership 入口对词表外的 policy 给出同向的拒绝 (#5205) - #5303

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5205-invalid-policy-outcome
Aug 4, 2026
Merged

fix(auth): 两个 membership 入口对词表外的 policy 给出同向的拒绝 (#5205)#5303
os-zhuang merged 1 commit into
mainfrom
claude/issue-5205-invalid-policy-outcome

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5205

问题

packages/plugins/plugin-auth/src/reconcile-membership.ts 里,同一个 policy 字段被两个函数用相反的谓词判断:

reconcileMembership:   if (deps.policy === 'invite-only') return { outcome: 'policy-skip' };
backfillMemberships:   if (deps.policy !== 'auto')        return { ...summary, reason: 'policy' };

传入词表外的值(拼错的 'inviteOnly'undefined、一个对象……)时:

函数 旧行为
reconcileMembership(注册路径,逐个绑定) 落到 auto 分支,照常自动绑定 —— fail-open
backfillMemberships(回填路径,批量) 判为非 auto,不绑 —— fail-safe

危险的是前者:调用方以为自己关掉了自动绑定,实际上没有,日志里也看不出来。

#5152 之后框架自身通路上不可能再出现非法值(两个调用点都走 AuthManager.getMembershipPolicy(),返回类型是 MembershipPolicy),但 index.tsexport * 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 边界上的姿态同源:非法值响亮拒绝,不静默强转#5152auth-plugin.ts 对一个词表外的 OS_AUTH_MEMBERSHIP_POLICY 就是这么做的(logger.error + 忽略 + 报出允许值),本 PR 只是把同一条纪律推到它下游的两个函数里,让这一层不再有第二种姿态。

代价是它改导出的联合类型 —— 正因如此才搭 v17 这班主版本车。

契约变更的影响面

导出物 变更
ReconcileOutcome 新增成员 'invalid-policy'
BackfillMembershipsResult['reason'] 新增成员 'invalid-policy'
BackfillMembershipsResult 新增可选字段 error?: string
reconcileMembership() 返回值 新增可选字段 error?: string
ReconcileMembershipDeps['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/mainreconcile-membership.ts,测试文件为本 PR 版本)真实执行:

 FAIL  src/reconcile-membership.test.ts > … > policy = camelCase typo > reconcileMembership refuses — invalid-policy, nothing bound
 FAIL  src/reconcile-membership.test.ts > … > policy = snake_case typo > reconcileMembership refuses — invalid-policy, nothing bound
 FAIL  src/reconcile-membership.test.ts > … > policy = wrong case > reconcileMembership refuses — invalid-policy, nothing bound
 FAIL  src/reconcile-membership.test.ts > … > policy = empty string > reconcileMembership refuses — invalid-policy, nothing bound
 FAIL  src/reconcile-membership.test.ts > … > policy = undefined > reconcileMembership refuses — invalid-policy, nothing bound
 FAIL  src/reconcile-membership.test.ts > … > policy = null > reconcileMembership refuses — invalid-policy, nothing bound
 FAIL  src/reconcile-membership.test.ts > … > policy = boolean > reconcileMembership refuses — invalid-policy, nothing bound
 FAIL  src/reconcile-membership.test.ts > … > policy = object > reconcileMembership refuses — invalid-policy, nothing bound

AssertionError: expected 'bound' to be 'invalid-policy' // Object.is equality
AssertionError: expected 'policy' to be 'invalid-policy' // Object.is equality
AssertionError: expected [ { …(2) } ] to have a length of +0 but got 1

 Test Files  1 failed (1)
      Tests  30 failed | 19 passed (49)

expected 'bound' to be 'invalid-policy'to have a length of +0 but got 1 是今天的 fail-open 被抓现行:一行 sys_member 真的写进去了。同一次运行里 19 passed —— 既有测试与新加的「合法值行为不变」表全绿,说明红的只有本 PR 要修的那条方向性错误。

修复后同一文件:

 Test Files  1 passed (1)
      Tests  49 passed (49)

测试

  • 8 个词表外的值 × 3 条断言(reconcileMembership 拒绝 / backfillMemberships 拒绝 / 两者同向且都不绑),并显式断言 outcome !== 'policy-skip'reason !== 'policy'
  • 拒绝发生在入口 —— resolveTargetOrg 一次都没被调用;
  • 非法值被记进日志与返回值;error 级而非 warn;只有 warn 的 logger 也收得到;完全不传 logger 时照样拒绝;
  • 非字符串值不把内容打进日志([object],断言 ops@example.com 不出现在消息与 meta 里);超长字符串截断;
  • 前置条件仍然优先:缺 engine 时是 skipped / engine-unavailable,不是 invalid-policy(两个函数的检查顺序保持对称);
  • 合法值逐条不变:对 MEMBERSHIP_POLICIES 表驱动,钉住 autobound / 绑 1 行、invite-onlypolicy-skip / reason: 'policy' / 绑 0 行,外加一条断言保证这张表覆盖了整个声明词表(以后往词表里加值,这条会先红)。

证据边界

  • pnpm --filter @objectstack/plugin-auth 全量:Test Files 34 passed (34) / Tests 780 passed (780)tsc --noEmit 退出 0。
  • 门禁:check:engine-double-contractcheck:adr-anchorscheck:doc-authoringcheck:org-identifiercheck:role-wordcheck:startup-registry-verdict 全 PASS;改动两文件 eslint 退出 0。
  • 下游 @objectstack/dogfoodtsc --noEmitmembership-reconciler.dogfood.test.ts@objectstack/plugin-auth 的 import 无报错(该文件唯一的错是第 25 行 @objectstack/verifyTS2307)。该包剩余 181 条错全部是 TS2307 Cannot find module,指向本会话未构建、且本任务禁止触碰的兄弟包(@objectstack/verifyplugin-securityplugin-audit …,organization/create 的闸门判「请求的 posture」还是「实际生效的 posture」?降级部署(D5)下两者分叉,闸门放行而 /auth/config 隐藏 #5261 正在改),属沙箱构建状态,与本改动无关。跑 dogfood 的运行时测试(要真实 DB)。
  • 跑仓库全量 test/build(资源纪律:按包 scope)。CI 由 PM 盯。

文件面

只碰了 packages/plugins/plugin-auth/src/reconcile-membership.ts 及其同名测试,加一个 changeset。ReconcileOutcome 的定义与导出都在这一个文件里(index.tsexport * from './reconcile-membership.js' 转出,未改)。未触碰 auth-manager.tsorg-create-posture-gate.test.tspackages/objectqlplugin-devruntimedriver-sqlclipackages/types/src/env.tspackages/qa/**packages/verify/**content/docs/releases/

🤖 Generated with Claude Code

https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t


Generated by Claude Code

`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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 4:24pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 17:03
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 7cf1531 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5205-invalid-policy-outcome branch August 4, 2026 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(auth): reconcileMembership 与 backfillMemberships 对「词表外的 policy」判断相反 —— 一个 fail-open,一个 fail-safe

2 participants