我们用自己的 SAML 登录自己的 admin 控制台。下面是它抓到的问题。
有一种 dogfooding 只是个口号,还有一种是:在 bug 修好之前,你自己的员工连代码都发布不了。我们践行的是后者。Authagonal 的员工控制台,也就是我们用来管理每一个 tenant 的那个,是通过 Authagonal 自己来认证的:来自我们 Entra 目录的 SAML single sign-on,由 SCIM 决定谁能进来、能做什么。没有单独的 admin 密码表。我们把它删掉了。如果我们自己的 SAML 坏了,我们就被锁在自己的产品门外。
这种别扭恰恰是有用的那种别扭。它把"SSO 是我们支持的一项 enterprise 功能"变成了"SSO 是构建出它的这些人今天能开工的唯一途径"。下面就是成为自己客户这件事所抓到的问题。
一个被 trim 过的 build 破坏了签名验证
我们以 trim 过的方式发布 auth server,以便让镜像保持小巧。Trimming 会激进地删除它无法证明被用到的代码,而反射会把这种使用从它眼前藏起来。.NET 是按名称、通过反射、经由 CryptoConfig 来解析它的 XML 签名加密算法的。Trimmer 看不出这些类型是必需的,便把它们删掉了,于是 SignedXml 悄无声息地变得无法构建出该算法。SAML 签名验证,也就是证明此次登录为真的那一步,在运行时抛出了一个空引用。
单元测试都通过了,因为它们跑的是未经 trim 的 build,那些类型在其中依然存在。只有 trim 过的生产构件会失败,而它恰恰在一个真人尝试登录的那一刻失败。如今我们以未经 trim 的方式发布 auth server,并对 trim 任何接近基于反射的加密的东西保持一份健康的不信任。如果我们只是支持 SAML,而不是靠它过活,那这就会是某个客户的事故报告,而不是我们自己的。
Provisioning 才是真正的登录
认证一个用户是 SSO 容易的那一半。困难的那一半是:决定他被允许做什么,并随着人员的进进出出让它保持同步。我们用 SCIM 来驱动这件事:Entra 的组成员关系被映射为角色,并在 token 签发的那一刻完成解析,而不是在创建账户时一次性复制过去。把某人加进正确的组,他在下一次登录时就拥有访问权;把他移出,访问权随之消失。我们自己的访问清单,就是用我们交付的那套组到角色的映射在做 dogfooding。
这套接线暴露出一类只在认证与授权分处两个不同系统时才存在的 bug:一个刚刚被 provision 的 admin 可能在他的角色尚未完全落定之前就完成了认证,从而让首次登录停留在一种有效但未获授权的状态。修复的位置在 provisioning 的顺序里,而不在 login 里,而你只有亲自当一回首次登录的新 admin,才能发现它。
一个本该是 403 的 500
最小的那个 bug 最让人难堪。当一个已认证的用户碰到了他没有权限的东西时,API 返回的是 500,而不是一个干净利落的 403,因为签发"forbidden"响应的那条代码路径依赖了一个在该 host 中没有接线进来的服务。一个被拒绝的请求本应是一个平静的、在预期之内的结果,而不是一个服务器错误。在你自己成为那个被拒绝的人之前,它都是看不见的。
我们最在意的那道边界
在这一切之下,是一个多 tenant 身份平台绝不能搞错的唯一一条规则:一个 tenant 绝不能变成 我们的 admin。我们不信任任何单一的检查。平台是它自己的 issuer,一个 tenant 无法冒认它的 slug;平台的签名密钥与每一个 tenant 的都是分开的,因此一个为某个 tenant 签发的 token 无法被当作平台 token 重放;而且平台存储会独立地强制执行平台角色。三道锁,因为其中任何一道失效后默认放行,代价就是整个产品。
关键所在
这些问题没有一个是被某个巧妙的测试抓到的。它们是被一个想干活却干不成的真人抓到的。这就是要把自己的产品用到它能伤到你的那个深度的理由:它把藏在系统之间缝隙里的 bug,转化成你赶在早饭前就修好的 bug,因为不修好你就发布不了。
我们这套控制台所依赖的一切(SSO、SAML、SCIM、MFA、审计日志)都包含在 Authagonal 的每一档套餐里,既不会被锁在某个档位之后,也不按连接计费。看看都包含什么。