我们关掉了一个危险的默认值,却没有迁移一行数据
即时预配是那种让企业级 SSO 看起来像魔法的功能。一名新员工通过公司的身份提供方登录,你的应用里还没有他的账户,而系统当场就从断言里创建了一个。没有人提工单,没有人发邀请,这个人直接就能开始工作。
换个方向来读,它也是一个功能:凡是控制着那个身份提供方的人,只要声称某个人存在,就能在你客户的租户里创建账户。当这个连接被严格限定在某一家公司的目录、并且目录里的每个人都本该有访问权时,这没问题。当连接是一个共享目录、或者一个承包商租户、又或者那种庞杂的联合体,其中身份提供方愿意为之背书的人群远远大于你客户本想放进来的人群时,就没那么好了。
我们的默认值是开启。不是因为有谁决定它该如此,而这恰恰是值得停下来琢磨的地方。它默认开启,是因为当初加这个字段时,表示它的那个布尔值叫做 DisableJitProvisioning,一个未设置的布尔值是 false,而 false 意味着「不要禁用」。对于一个没人选择过的默认值,最稳妥的理解是它出于偶然,而这一个已经沉淀成了行为。
把暴露面说准确一点,因为它从来没有糟糕到「任何人都能创建任何人」的地步:在预配之前,已经有两道关卡在跑。一个连接可以带一份允许的邮箱域名清单,落在清单之外的断言会被拒绝。一个连接可以要求一个邀请属性,未受邀请的用户会被拒绝。「默认开启」真正的风险,在于一个两者都没有配置的连接,而这正是某人为了让 SSO 跑起来而匆忙搭起来的那种连接的样子。
翻转默认值是一个词。安全地翻转它不是。
所有人想到的改法,是把字段改名为 JitProvisioningEnabled,让它默认为 false。新连接默认就是安全的,搞定。
只不过这个字段是被持久化的,而存储里有一些连接是在它存在之前就写入的。它们的行里根本没有这一列。它们会怎样,完全取决于布尔值指向哪个方向,因为一列缺失在两种情况下都会反序列化为 false。在旧的否定式名字下,缺失意味着「未禁用」,预配继续。在新的肯定式名字下,缺失意味着「未启用」,预配停止。
所以一次直白的改名,会悄悄地把即时预配关掉,对每一个客户当初在它开启时配置的连接都是如此。他们什么也没选,没人告诉他们,而他们最先得知这件事,是某个员工登录不了,无论那发生在几点。这不是一次安全改进,这是一次靠部署交付的故障。
显而易见的办法是做一次回填:遍历每一个已存储的连接,显式地写入这一列,然后再翻转默认值。它管用,而它是一次你必须编写、必须测试、必须针对每个租户的存储运行、并且必须确保在依赖它的代码上线之前处处都已完成的迁移。就为了一个布尔值。
双重否定
我们没有写那次迁移。已存储的那一列永远保留它旧有的否定含义,而模型在它前面多出一个肯定式属性:
public bool JitProvisioningEnabled { get; set; }
public bool DisableJitProvisioning
{
get => !JitProvisioningEnabled;
set => JitProvisioningEnabled = !value;
}
那个肯定式属性才是真正的那个,背后有真正的存储,而它默认为 false,这就是新的安全默认值。那个否定式名字现在是一个计算出来的别名,在两个方向上都做反转。
跟着一条旧行走一遍。列缺失,所以它被读为 false,所以 DisableJitProvisioning 的 setter 以 false 运行,所以 JitProvisioningEnabled 变成 true。连接照旧预配,和它的所有者当初配置的一模一样,而什么也没被迁移。再跟着一个新连接走一遍。谁也没设置这两个属性中的任何一个,JitProvisioningEnabled 停在它默认的 false,而连接会拒绝未知用户,直到有人主动开启。
两种行为都出自同一段代码,没有分支,没有版本开关,也没有动过任何数据。被持久化的那个位从未改变含义。改变的只是它落进去的那个字段,而反转发生在一个每次加载都会运行的属性 setter 里。
它的代价
这不是免费的,账单在 API 边界处到来。两个属性都是公开的,所以两个都会被序列化,而一个读取连接、改动某处、再写回去的客户端,如今发送的是两个描述同一件事的属性。反序列化按它们在载荷中出现的顺序应用它们,所以最后一个胜出。把肯定式属性设为 true,却在你取回来的对象里留下一个陈旧的否定式属性,你的改动就会被一个你根本没想到自己在发送的字段悄悄撤销。
我们是以发现这类事情的方式发现它的:在一个把标志打开、然后断言它已开启的测试里。由此得出的规则是,在任何读-改-写中都显式地设置两种形式,我们自己的端到端测试如今就这么做,还配了一条注释解释为什么。如果你采用这个技巧,请把它算进预算。一个双向别名给你换来一次免费的迁移,却向你收取一份线上的歧义。
这次翻转揭出的 bug
接下来是那部分可以推广到布尔值之外的内容。在做这次改动时,我们发现用来创建 OIDC 连接的管理端点从来就没有设置过这个标志。不是设错了,也不是设成了错的值。它就是从来不去赋值,而请求对象里根本没有一个字段可供赋值。
只要默认值还是所有人都想要的那个值,这一点就是不可见的。每个连接出来时都在预配,而这正是那段忘了接线的代码无论如何也会产生的结果,所以没有什么可察觉的,也没有哪个测试能失败。默认值翻转的那一刻,同一个缺口就变成了「每一个新创建的 OIDC 连接都关着预配,而且没办法把它打开」,那可一点都不是什么微妙的 bug。
一个默认值,就是每一条忘了设置该字段的代码路径的取值。只要默认值还好用,这些路径就和那些刻意设置该字段的路径无从区分。改变一个默认值,改变的不只是新的行为,它把照片冲洗了出来:一切曾经默默依赖这个默认值的东西,一下子全都显形,而其中有些是坏的。
那个没有挪动的复选框
默认值藏身的最后一处地方是用户界面。我们的门户曾有一个复选框,写着「禁用 JIT 预配」,默认不勾选。它现在写着「启用 JIT 预配」,而且仍然默认不勾选。同一个控件,在同一个位置,带着同样的初始状态,含义却相反。
那是一种真正危险的改动,于是列表视图多了一个徽标。任何没有主动启用的连接现在都被打上标记,这样状态无需打开任何东西就能看到,而不是从一个曾经表示相反意思的未勾选方框里去推断。
而当一个关着预配的连接收到一份针对某个未知者的断言时,用户不会被丢在一段堆栈跟踪上。他会带着一条错误信息回到他来的那个应用,信息说找不到该账户、请联系管理员,这才是刚刚发生的事情那个真实而可行动的版本。
默认值就是 API 表面,被四类人群继承
默认值就是 API 表面。它们被那些早于该字段的已存储行所继承,被那些省略了它的配置文件所继承,被那些从不设置它的代码路径所继承,也被那些以未勾选状态编码着它的界面控件所继承。在你挪动一个默认值之前,请把这四类人群一一列出,并为每一类决定:它应该跟随新的默认值,还是保留旧的行为。通常每一类的答案都不一样,而这正是设计工作所在。
而如果你发现自己正打算写一次数据迁移去挪动一个布尔值,先看看能不能让含义留在原地,而让名字和默认值挪到它前面去。存储是你改主意时那个昂贵的地方。属性 setter 是那个便宜的地方。
如果你更希望自己的身份提供方在出厂时就已经选好了审慎的默认值,Authagonal 让每一个 SSO 连接都必须主动选择开启预配,并明明白白地告诉你哪些已经开启了。