Skip to content

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

Closed

Description

@os-zhuang

观察类 finding,来自 #5152 的实现。默认部署撞不到:#5152 之后两个调用点都走 AuthManager.getMembershipPolicy(),返回类型是 MembershipPolicy,词表外的值进不来。留档是因为这两个函数是 @objectstack/plugin-auth 的公开导出(index.ts 的 export * from './reconcile-membership.js'),JS 侧调用方或 cloud 宿主可以绕过类型直接传。

事实(reconcile-membership.ts)

同一个策略字段,两个函数用相反的谓词:

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

传入 'inviteOnly'(或任何拼错的值)时:

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

两者对同一个输入给出相反的姿态。真正危险的方向是前者:调用方以为自己关掉了自动绑定,实际上没有。

为什么现在只是 finding

可能的形状

  1. reconcileMembership 改成与回填同向的 fail-safe:if (deps.policy !== 'auto') return { outcome: 'policy-skip' };。一行,语义收紧,且与「墙的默认姿态是有墙」一致。风险:policy-skip 这个 outcome 名对「值非法」这个原因是撒谎,排障时会误导;
  2. 两处都在入口用 isMembershipPolicy()(membershipPolicy 无法作为平台设置配置,且注册路径与回填路径读的是两个来源 #5152 新增的类型守卫)显式判非法,给一个独立的 outcome/reason(如 'invalid-policy')并记 error。信息量最足,改动面也最大(ReconcileOutcome 是导出的联合类型,加成员对下游是 minor);
  3. 什么都不做——两个谓词的分歧记在这里,等哪天真有外部调用方再说。

倾向 2:与 #5152 在 settings 边界上采取的姿态(非法值响亮拒绝、不静默强转)一致,且不会让 outcome 名撒谎。但它改的是导出的联合类型,属于契约变更,需要维护者定夺。

Found-during: #5152

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 4, 2026
  2. claude commented on Aug 4, 2026

    @claude
    Contributor

    ⛔ 认领:session_015W6nhsDrz6zWQc8je12a1t(objectstack:identity 车道 PM),分支 claude/issue-5205-invalid-policy-outcome,os-dev 派发(排在 #5261 之后,两条同属 plugin-auth 包,按同包串行纪律不并发)。

    维护者 2026-08-04 裁定:本条进 v17,取形状 2(两处入口用 isMembershipPolicy() 显式判非法,给独立的 outcome/reason 如 'invalid-policy' 并记 error)。理由与本单倾向一致:与 #5152 在 settings 边界上的姿态(非法值响亮拒绝、不静默强转)同源,且不会让 policy-skip 这个 outcome 名对「值非法」撒谎。它改的是导出的联合类型 ReconcileOutcome(对下游是 minor),属契约变更——正因如此才要搭 v17 这班主版本车。

    真正要消灭的是那个方向性错误:reconcileMembership 今天对词表外的值 fail-open(调用方以为关掉了自动绑定,实际照绑),而 backfillMemberships fail-safe。两者对同一输入姿态相反。


    Generated by Claude Code

  3. claude commented on Aug 4, 2026

    @claude
    Contributor

    PM 验收(第 19 轮,identity 车道):ACCEPT — PR #5303,CI 绿后合入

    裁定的形状 2 落地到位,reconcileMembership 的 fail-open 消灭了:两个入口对同一个词表外的值给出同向的拒绝,并各自返回独立的 'invalid-policy' 而不是继续用 policy-skip/policy 冒名顶替。

    为什么形状 2 值这个代价,dev 的论证比 issue 原文更到位:policy-skip 的语义是「一个合法的策略说了不」,拿它报告「你传的这个值根本不是策略」,会把排障的人指向部署设置去看 —— 而那个设置好端端的。形状 1 少写的那几行,日后要在一次线上排障里连本带利还回来。同时它指出形状 2 与 #5152 在 auth-plugin.ts 对词表外 OS_AUTH_MEMBERSHIP_POLICY 的姿态同源(logger.error + 忽略 + 报出允许值),本 PR 只是把同一条纪律推到下游两个函数,让这一层不再有第二种姿态 —— 「一层里只有一种姿态」正是这条改动的长期价值。

    契约变更的影响面我独立核实过,dev 列的三个仓内消费点属实,均不受影响:

    • admin-user-endpoints.ts:308 → 只读 result.outcome === 'bound' 与 result.organizationId;
    • auth-manager.ts:3771 → 完全忽略返回值(best-effort hook,外面还包着 try/catch);
    • auth-plugin.ts:887 → 只读 res.bound。

    唯一会被顶到的是对这两个联合类型做穷尽 switch(never 收尾或 Record<ReconcileOutcome, T> 全映射)的消费者,仓内没有。changeset 记 minor 正确。

    error 的处置很克制:同时进日志和返回值(后者是为了让没传 logger 的调用方也拿得到 —— 拒绝本身不该是日志的副作用,这个判断对);字符串原样回显并在 64 字符处截断('inviteOnly' 与 'invite-only' 的区别正是这条消息的全部意义),非字符串只报 typeof —— 因为那是脱离类型契约到达的任意调用方数据,日志行不是发现它带着用户字段的地方。这条 PII 边界是它自己想到的,派单只提了一句「你自己判断一下」。

    对抗性验证真实执行:对 origin/main 的未修复源码跑本 PR 的测试,camelCase/snake_case/大小写错/空串/undefined 等各条 reconcileMembership refuses — invalid-policy, nothing bound 全部变红,即今天的 fail-open 被逐个钉死。合法值两条路径的既有行为另有测试守住。

    CI 绿即合入。⏳ draft 翻转需 GraphQL,额度当前为 0(约 37 分钟后恢复),恢复即执行 —— #5302 同此。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

findingRecorded observation — held, not dispatchable until the findings triage round grades it

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions