背景
审查 seed-loader 的「失败被静默吞掉却报告成功」类问题(#2805)时,顺带审计到 admin-import-users 的一个同类隐患:temporary 策略下 engine.update('sys_user', { must_change_password: true }) 失败时只打 warn、不 surface,账号仍以 created/success 返回,且临时密码照常下发——结果是一个带临时密码却未被强制轮换的账号静默存在。
单用户路由 admin-user-endpoints.ts 的 stampMustChangePassword 已经把这条设计立场写成文档:闸是 fail-open、best-effort,失败不阻断操作,但要 surface 真实状态。admin-import-users 只是没对齐这条立场(连状态都没 surface)——那部分作为 bug 单独修。
本 issue 记录的是更上一层的产品/架构建议:与其在最脆弱的 temporary 路径上继续加固,不如减少对它的依赖。
问题定位:temporary 是最弱且负债最重的策略
导入已支持三种 policy(admin-import-users.ts):
| 策略 | 机制 | 是否分发长期共享秘密 |
|---|
temporary | 生成临时密码、返回一次、靠 must_change_password 强制轮换 | 是(经不安全渠道分发) |
invite | 无凭证账号 + 一次性邀请链接(邮件/短信)→ 用户自设密码 | 否 |
none | 仅身份,凭证事后经重置流按需铸造 | 否 |
临时密码是 95+ bit 高熵,所以风险不是被爆破,而是:
- 分发渠道暴露:临时密码必然经不安全渠道(Slack / 邮件 / 口头 / 便签)交到用户手里,整个安全模型全押在「用一次就被强制换掉」;
- 一旦强制轮换的闸没落下(就是上面那个 bug),它就退化成经弱渠道分发、却永久有效的凭证;
- 批量放大:一次 DB 抖动可能让一整段连续行盖闸失败,几十个账号同时留永久临时密码,现场只有 warn 日志、无人察觉。
invite/none(以及已挂载的 magicLink 插件)全程不分发任何长期共享秘密,因此根本不需要 must_change_password 这道会 fail-open 的事后补写闸——那道闸本质是 temporary 专属的负债。
建议
把导入默认策略从 temporary 挪到 invite,temporary 只对不可投递的行兜底。
- 有可投递的邮箱 / 手机号 → 走
invite(一次性链接,用户自设密码); - 仅对 placeholder 邮箱 / 纯手机号 / 明确不可投递的行 → 回退
temporary。
这一步不改任何安全机制,只改策略选择逻辑,却能把临时密码的爆炸半径从「整批」收缩到「真正没渠道的少数行」,随之大幅缩小上述 bug 的影响面。
待确认(决定该不该直接落地)
部署环境是否普遍具备可用的邮件 / 短信投递?
- 若普遍具备 → 本建议直接成立,
temporary 应退居兜底; - 若很多是内网 / 无投递 →
temporary 是刚需,方向应转为「把临时性下沉到凭证自过期(credential_expires_at),从架构上让它 fail-closed」,作为独立 issue 跟进。
关联
背景
审查 seed-loader 的「失败被静默吞掉却报告成功」类问题(#2805)时,顺带审计到
admin-import-users的一个同类隐患:temporary策略下engine.update('sys_user', { must_change_password: true })失败时只打 warn、不 surface,账号仍以 created/success 返回,且临时密码照常下发——结果是一个带临时密码却未被强制轮换的账号静默存在。单用户路由
admin-user-endpoints.ts的stampMustChangePassword已经把这条设计立场写成文档:闸是 fail-open、best-effort,失败不阻断操作,但要 surface 真实状态。admin-import-users只是没对齐这条立场(连状态都没 surface)——那部分作为 bug 单独修。本 issue 记录的是更上一层的产品/架构建议:与其在最脆弱的
temporary路径上继续加固,不如减少对它的依赖。问题定位:
temporary是最弱且负债最重的策略导入已支持三种
policy(admin-import-users.ts):temporarymust_change_password强制轮换invitenone临时密码是 95+ bit 高熵,所以风险不是被爆破,而是:
invite/none(以及已挂载的magicLink插件)全程不分发任何长期共享秘密,因此根本不需要must_change_password这道会 fail-open 的事后补写闸——那道闸本质是temporary专属的负债。建议
把导入默认策略从
temporary挪到invite,temporary只对不可投递的行兜底。invite(一次性链接,用户自设密码);temporary。这一步不改任何安全机制,只改策略选择逻辑,却能把临时密码的爆炸半径从「整批」收缩到「真正没渠道的少数行」,随之大幅缩小上述 bug 的影响面。
待确认(决定该不该直接落地)
部署环境是否普遍具备可用的邮件 / 短信投递?
temporary应退居兜底;temporary是刚需,方向应转为「把临时性下沉到凭证自过期(credential_expires_at),从架构上让它 fail-closed」,作为独立 issue 跟进。关联
admin-import-users对齐admin-user-endpoints.ts的stampMustChangePassword(重试 + 逐行 surface 真实盖闸状态),单独修复。