Skip to content

feat(spec,auth): guard that every feature-gated capability is UI-gated (generalize the create-user phone fix) #2874

Description

@os-zhuang

背景

create-user phone bug(objectui #2406 + framework #2871 已修)是一个更广 bug 类的实例:

UI 宣传了一个 runtime 没有的能力,因为背后的插件没开。

create_user 表单提供 phoneNumber 字段,但默认后端在 phoneNumber auth 插件未加载时拒绝它。修复用既有谓词模式 gated:visible: 'features.phoneNumber == true'sys-user.object.ts ~L160)。邻居字段本来就 gated 对了(create_user 本身 features.admin == true ~L143,org 字段 features.organization != false ~L66),唯独 phone 漏掉——说明 per-site 手工 gating 纪律必然出静默缺口

v2(2026-07-15)重写说明:原版把「CI 守卫」列为必做、把声明式 requiresFeature 列为可选——依赖方向反了:没有声明载体,「input 依赖哪个 feature」这一知识只存在于插件运行时行为里,静态检查没有 ground truth,原验收「fails if a capability-dependent input ships ungated」不可实现。另外原版的 flag 清单(8 个)在开出当天就已过时(实际 13 个,见下)——恰好自证了手工清单必漂移的论点。本次重写:修正清单、按消费面拆分审计范围、把工作重排为 P0 注册表 → P1 声明式标注 → P2 审计,验收改为可机械判定的两条守卫。标签去掉 security(后端本来就 fail-closed 拒绝,这是可用性/诚实宣传问题,不是权限提升;反向类「UI 隐藏但后端接受」才是安全面,不在本 issue 范围)。

已核实现状(2026-07-15,落点级)

getPublicConfig().featuresauth-manager.ts ~L2663-2701)实际暴露 13 个布尔 flag(另有 termsUrl/privacyUrl 两个非布尔,豁免),按消费面分两类:

① Admin/CRUD 面(action params / object fields,经 objectui filterVisibleParams 渲染链)——本 issue 的主审计面:

flag 默认 现有 gating
admin false(SCIM 强制开,ADR-0071/#2766 sys-user.object.ts:143
organization true sys-user/team/member/invitation 多处 != false
multiOrgEnabled false(OS_MULTI_ORG_ENABLED sys-organization.object.ts ×6 处 != false
phoneNumber false sys-user.object.ts:160(本次修复)

② 登录面(objectui 登录 UI 直接消费 /auth/config,不走 param 渲染链)——twoFactor / passkeys / magicLink / sso / ssoEnforced / phoneNumberOtp / oidcProvider / deviceAuthorization。这条链已有刻意设计:phoneNumberOtp 仅在 SMS 可投递时 advertised(~L2698,#2780),ssoisSsoUsable() 细化以隐藏死按钮(~L2678-2682)。审计预期以核对为主而非补漏。

③ 状态旗——degradedTenancy(ADR-0093 D5 降级横幅):不是输入门,豁免并注明。

原版清单漏掉的 5 个:multiOrgEnabled(已被 6 处谓词消费!)、degradedTenancyoidcProviderssoEnforceddeviceAuthorization

任务(按依赖排序)

  • P0 — flag 分类注册表 + 清单漂移守卫(~1 PD,立即可做,抓最大漂移类)
    spec 侧建单一注册表:每个 flag 记录 {消费面: crud|login|status, 默认语义: == true | != false, 映射的 spec inputs 或豁免理由}。测试断言 getPublicConfig().features 的 key 集合 ≡ 注册表 key 集合——新增 flag 未分类即 CI 红(正是本 issue 自己漏 5 个 flag 的那类漂移)。
  • P1 — 声明式 requiresFeature 语法糖(~1-2 PD,依赖 P0 的语义表)
    param/field schema 支持 requiresFeature: 'phoneNumber',spec 加载期查 P0 语义表 lower 成 visible: 'features.phoneNumber == true'(或 != false)——objectui 渲染链(filterVisibleParams零改动。既有手写 CEL 门迁移为标注(行为等价,矩阵锁定)。
  • P2 — 双面审计 + 注册表完整性守卫(~1-2 PD,依赖 P0)
    ① CRUD 面:枚举 platform-objects 全部 action params / object fields,凡后端路径依赖注册表 flag 者补 requiresFeature;② 登录面:核对 objectui 登录 UI 对 8 个登录 flag 的消费,缺口记录或修复;③ 守卫:注册表中「需 gating」flag 映射的每个 input 必带匹配谓词,CI 断言。

显式非目标

  • 「任意新 input 忘了标注必被 CI 抓住」不承诺——「这个 input 依赖 feature X」的 ground truth 只在插件运行时行为里,静态不可判定。声明式标注把纪律成本降到最低,注册表守卫抓 flag 级漂移;运行时兜底属于 dogfood 矩阵(插件关闭 → advertised 表单逐项探测),如需要另立 issue。
  • 反向安全类(UI 隐藏但后端接受)不在范围。

验收

  • P0 注册表存在,13 flag 全部分类(gated / 豁免+理由);清单漂移测试在,故意加一个未分类 flag 会红。
  • P1 requiresFeature 在 spec 支持并 lower 正确(两种默认语义各有测试);objectui 无改动即生效;既有手写门迁移后行为矩阵无 delta。
  • P2 审计结论落在注册表里;完整性守卫在,故意去掉一个已注册 input 的谓词会红。

参考

Surfaced during go-live UAT while confirming the phone-field fix. Rewritten v2 after site-level verification.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions