To B 产品在签单前几乎一定会被问:「能不能对接我们现有的账号体系?」企业不接受「让我三千个员工再注册一遍」,也不接受「员工离职了你们那边账号还能用」。所以单点登录不是一个可选功能,它是准入条件。
第一版是为第一个客户直接写死的:客户用的是企业微信,我们就照着企业微信的文档实现了一套登录。第二个客户用钉钉,又写了一套;第三个客户自建 SAML,再写一套。三套代码里的会话创建、权限初始化、审计日志各写了一遍,而且不完全一致——后来发现其中一套忘了写登录审计日志,安全评估时被客户指出来。
所以第一个设计是协议适配层:各协议的差异只在适配层处理,输出统一的「已验证身份」模型(外部身份标识、姓名、邮箱、部门信息、身份源),之后的会话创建、权限初始化、审计全部走同一条链路。这层抽象的目的很具体:接入新客户从「改代码」变成「加一份配置」。
第二个,也是这个模块真正的重点:安全校验极易做错,而做错的后果是可以伪造任意人的身份登录。这不是「有个漏洞」,是整个系统的身份基础被绕过。三类错误都是常见的:
一是不验签或验签不严。拿到断言直接解析里面的用户信息就用。断言是通过浏览器转发的,用户完全可以自己构造一个,不验签等于让任何人声称自己是任何人。还有一种半错的情况:验了签但接受断言里自带的算法字段,攻击者把算法改成 none 或改成对称算法,验签就被绕过。正确做法是算法由我们的配置决定,不看断言里写什么。
二是不校验 Audience 和 Issuer。验签通过只说明「这个断言是某个我们信任的身份提供方签的」,不说明「它是发给我们的」。如果同一个身份提供方也给别的服务发断言,攻击者可以把发给别家的断言拿来登录我们。Audience 必须等于我们自己的标识,Issuer 必须在该租户配置的白名单里。
三是不防重放。断言在有效期内可以被反复使用,攻击者截获一次就能重复登录。所以要时间窗校验(拒绝过期和签发时间在未来的)加断言 ID 一次性消费(记录已用过的 ID,窗口内去重)。
第三个设计是绑定表。第一版直接把外部身份 ID 当成内部用户表的主键,看起来省事,但两个问题:客户换身份供应商(从企业微信换成飞书)时所有 ID 都变了,用户的历史数据全部失联;客户要求多身份源并存(总部用 AD、分公司用钉钉)时压根表达不了。所以外部身份和内部账号用独立的绑定表关联,一个内部账号可以绑多个外部身份。
最后是首次登录怎么处理。SSO 验证通过但系统里还没有这个账号——是自动开通还是转审批?这必须是租户策略而不是我们定,因为它涉及授权:有的客户希望员工登录即可用(信任自己的身份体系),有的要求管理员逐个批(授权要留痕)。
为什么做适配层而不是为每个客户单独实现。单独实现在客户少的时候更快,但它的问题不是重复代码,而是「安全校验和审计这类横切逻辑会写得不一致」——我们就出现过三套实现里有一套漏了登录审计。适配层把差异(协议解析)和共性(校验、会话、权限、审计)分开,共性只有一份实现,安全校验也只需要在一个地方保证正确。取舍是初期多花了设计时间,而且适配层要吸收各协议的怪异之处(有的 IdP 不返回邮箱、有的部门信息在自定义字段里),这部分复杂度是逃不掉的,只是被关在了适配层里。
验签算法为什么必须由配置决定,而不是读断言里的算法字段。读断言里的算法字段看起来「更通用」——同一个校验逻辑能处理不同算法。但这等于让不可信输入决定校验方式,攻击者把算法改成 none,验签就被跳过;改成对称算法并用公钥当密钥签名,也能通过。正确的原则是:校验方式必须由我们的配置决定,不能由被校验的数据自己声明。这条判断不只适用于 SSO——凡是「数据自带元信息告诉你怎么处理它」的地方都要警惕。
踩过的坑:验了签但没校验 Audience,测试时被安全同事用另一个服务的断言登了进来。那个身份提供方同时服务多个系统,安全同事拿到一个发给别的系统的合法断言,直接在我们这里换到了会话。当时的逻辑是「签名对了就是可信的」,这个理解是错的——验签证明的是「谁签的」,不是「给谁的」。修法是强制校验 Audience 等于我们的标识、Issuer 在租户白名单内。教训是:认证里「可信来源」和「可信目标」是两个独立的条件,必须分别校验,只验一个等于没验。
踩过的坑二:外部身份 ID 直接当内部用户主键,客户换供应商时数据全部失联。一个客户从企业微信换到飞书,所有员工的外部 ID 全变了,他们的历史审批记录、待办、操作日志全都挂在旧 ID 上找不回来,只能靠邮箱做一次性数据迁移,还有一批邮箱不一致的需要人工核对。修法是引入独立绑定表。教训是:不要让外部系统的标识成为自己数据模型的主键——外部标识的稳定性不由你控制,它一变你的数据关系就断了。这条在第三方支付流水号、供应商商品编码上同样成立。
踩过的坑三:安全校验在一次重构里被误删,两周没人发现。有人重构登录链路时把重放校验的那段挪走后忘了接回去,功能测试全部通过(因为正常登录不受影响),只是不安全了。是后来做安全复查才发现的。修法是把每一项安全校验写成独立的自动化用例并纳入 CI:构造过期断言、重放断言、Audience 错误的断言、算法为 none 的断言,断言这些都必须被拒绝。教训是:安全校验删掉之后功能完全正常,所以它没有任何自然信号,必须靠用例守住。
没做的部分:没做 SCIM 标准的用户供给协议(让客户的 IdP 主动推送用户变更)。当时是我们主动拉取(见模块二),SCIM 更实时但需要客户侧也支持,那时客户里支持的不多。也没做多因素认证的自建实现,MFA 一律依赖客户自己的 IdP 完成。
接入成本:这是这个模块最该报的数字,而且它是结构性对比而不是耗时数字——改造前接入一家新客户的身份体系需要改代码并发版,改造后是新增一份适配配置。「从改代码到加配置」这个对比比「接入耗时从 5 天降到 1 天」更能说明设计质量,因为后者依赖具体客户的配合速度,不完全是我们的成果。
安全校验的覆盖:报写了哪几类攻击用例并且都在 CI 里回归:过期断言、重放断言、Audience 不匹配、Issuer 不在白名单、算法为 none、签名被篡改。这个列表本身就是最好的证明,比说「做了安全加固」具体得多。
安全评估结果:客户方的安全评估或渗透测试的问题数与整改情况。诚实的表述是「首次评估发现 N 项、均已整改并补上回归用例」——承认发现过问题比声称一次通过可信,而且「补上回归用例」这半句说明是从机制上修的。
登录失败的原因分布:报各类失败原因的占比(验签失败、Audience 不匹配、时间窗超出、账号未开通)。这个数据有实际价值:如果时间窗超出占比高,可能是客户服务器时钟不同步,是个可主动发现的客户侧问题。
不要报「登录成功率 99.99%」。它混合了客户 IdP 的可用性、网络、客户端行为,不是我们的设计成果。也不要报「零安全漏洞」——安全类的绝对化断言最容易被问穿,而且只要将来出一次就被推翻。正确表述是「三重校验各覆盖什么攻击、用例在 CI 中回归、安全评估发现的问题及整改」,这是可核查的。
组织架构在企业服务里是一切授权的底座:谁能看哪些数据、谁是谁的审批人、报表按什么维度汇总,全都依赖它。但它有一个根本特点——这份数据不是我们的,是客户的,而且一直在变。
第一版的做法是全量覆盖:每天凌晨把客户的组织架构完整拉一遍,清空重建。简单,但问题一堆。
第一个问题是部门改名被当成了「删旧建新」。我们早期用部门路径(比如「公司/技术中心/后端组」)作为部门的标识,因为客户系统里的部门 ID 看起来不太直观。结果客户把「技术中心」改名成「研发中心」,路径全变了,同步逻辑认为原来那一整棵子树的部门都被删除、又新建了一批部门。后果是:挂在旧部门上的所有权限配置失效、审批人规则解析不到部门主管、成员的数据可见范围突然变空。那天早上客户的报销流程全部走不动。
根因很清楚:用会变的东西做标识。正确做法是以客户系统里的稳定外部 ID 为准,路径只是展示用的派生信息。部门改名就只是一次名称字段的更新,层级移动就只是父 ID 的更新,都不会影响身份。
第二个问题是全量覆盖丢失了「变更语义」。下游需要知道的不是「现在的组织架构长什么样」,而是「发生了什么变化」——有人离职了要回收权限,有人调岗了要重算数据可见范围,部门主管换人了要更新在途审批的解析。全量覆盖只给出最终状态,下游只能自己做一次全量比对,每个下游都比一遍,既慢又容易不一致。
所以改成增量拉取 + 把差异转换成结构化变更事件:入职、离职、调岗、部门新增/改名/移动/删除、汇报线变更。下游订阅事件做增量处理,不再各自做全量比对。
第三个问题是客户侧也会出错,而且是成批出错。有一次客户的 HR 系统导出异常,一次增量里带来了大批「离职」记录。如果照单执行,那就是大面积权限回收加账号停用——客户第二天全公司登录不了。这和旅游那边供应商推乱码数据是完全同一类问题,所以解法也一样:在批次层面做异常占比拦截(离职占比、部门删除占比超过阈值就整批挂起并告警),而不是逐条执行。
第四个问题是平台侧的手工调整被同步覆盖。有些信息客户的身份系统里没有(比如我们系统内的角色分配、某个人的额外数据权限),运营在平台侧手工配了,第二天同步又把它抹掉了。解法和房源那边的「修正层」一致:把平台侧手工调整独立分层,同步只写「来自客户系统」的那一层,合并时手工层优先。
用外部 ID 而不是路径做标识,这是这个模块的根本决策。当初选路径的理由是「客户系统的部门 ID 不直观,排查问题时看路径更方便」——这是个把「便于人阅读」和「适合做标识」混为一谈的错误。标识的唯一要求是稳定,可读性应该靠展示层解决。取舍上其实没有取舍,这一条是纯粹的对与错;真正的成本是发现错误后的数据修复:要把已有的部门重新对齐到外部 ID,并且修复那批因误判而失效的权限配置。
为什么全量校准也不做清空重建。清空重建的实现最简单,而且能保证最终状态一定和客户一致。但它有两个致命问题:一是执行期间权限是空的,哪怕只有几秒,那几秒里所有鉴权都会失败;二是丢掉变更语义,下游拿不到「谁离职了」这样的信息,只能自己比对。所以全量校准的正确形态是「拉全量、算差异、发事件」,它和增量的区别只是数据来源,处理链路完全一样。这个统一让全量和增量共享同一套异常拦截和事件生成逻辑,少一套代码就少一处不一致。
踩过的坑:用部门路径做标识,客户改个部门名导致全公司报销流程走不动。客户把「技术中心」改名「研发中心」,整棵子树的路径全变,同步认为原来的部门都被删除又新建了一批:挂在旧部门上的权限配置失效、审批人规则解析不到部门主管、成员的数据可见范围突然变空。第二天早上客户的报销流程全部卡住,是客户打电话过来我们才知道。修法是改用稳定外部 ID 建模,路径降级为展示信息。教训是:标识的唯一要求是稳定,不要因为「便于阅读」而选一个会变的字段做标识——可读性靠展示层解决。
踩过的坑二:客户 HR 系统导出异常,一次增量里带来大批离职记录。幸运的是当时同步任务因为别的原因失败重试,被值班同学看到了异常的记录量才没有执行下去——纯属运气。修法是批次级异常占比拦截,并按客户分别设阈值。教训和数据同步那边完全一致:异常检测的粒度要和异常发生的粒度一致,客户系统是成批出错的,逐条校验对成批错误结构上无能为力。而且这次的后果比脏数据严重得多——它直接影响的是「谁能登录、谁有权限」。
踩过的坑三:调岗后旧部门的数据还能看。同步把人员的部门字段更新了,但没有触发数据可见范围的重算,那个人调岗到别的部门后仍然能查到原部门的报销和合同数据,是客户内审时发现的。修法是把调岗作为一个显式事件驱动下游重算,而不是只更新一个字段等着下游自己发现。教训是:数据同步不能只同步「状态」,必须同步「变更事件」——状态更新是静默的,事件是可以被订阅和处理的。
踩过的坑四:同步任务挂了三天没人发现。期间客户新入职的员工登不进来(没有账号)、离职的人还能正常使用,而监控上没有任何异常,因为「同步没跑」不产生错误日志。修法是给同步任务加心跳监控:预期时间窗内没有成功完成就告警。教训是:定时任务的失败是静默的,「没有发生」不会产生信号——这和审批卡单是同一类问题,需要外部视角来判断「本该发生的事情有没有发生」。
没做的部分:没做 SCIM 协议的被动接收(让客户 IdP 主动推送变更),当时客户里支持的不多,主要靠我们主动拉。也没做组织架构变更的影响面预演(「这次同步会导致多少人权限变化」的预览),有了它运营就能在执行前判断合理性,是个明显的能力缺口。
部门改名导致的权限失效:确定性验证。构造场景——同步一个部门改名与层级移动,检查该部门的成员、权限配置、审批人解析全部不受影响。改造前必然全部失效,改造后不受影响。这类正确性用确定性用例覆盖,不要用比率。
批次拦截:报实际拦下的异常批次数与类型。最有说服力的是那次真实案例:「一次客户侧组织导出异常带来大批离职记录,在批次校验环节被挂起」——具体、可核查、能追问。同时要主动报误拦率以及「阈值按客户分别设」这个做法,否则会被问「快速扩张的公司会不会天天被拦」。
调岗后的权限回收:确定性验证——调岗后检查原部门数据不可见、新部门数据可见。这是内审会查的项,要有固定用例。
同步的及时性:报从客户侧发生变更到平台侧生效的时长,分增量路径和全量校准路径分别报。要说明这个时长受客户接口的推送频率影响,不完全由我们决定。
同步任务的健康度:报心跳监控触发告警的次数,以及最长的一次未同步时长。诚实报出「曾经有一次同步中断了三天才发现,之后加了心跳监控」比声称从未中断可信,而且它说明改进是有针对性的。
手工调整层的规模:这是个健康度指标。手工层持续增厚说明客户的组织数据不完整或我们的模型缺字段,报它的规模和趋势比报一个静态准确率更有意义。
不要报「组织数据一致性 100%」。客户侧的数据本身就有滞后和错误(HR 系统里离职流程走完可能比实际离职晚几天),端到端不可能完全一致。正确表述是「同步延迟多少、拦下了哪些异常批次、哪些正确性由确定性用例保证、手工层规模趋势」,把责任边界说清。
离职权限回收在企业客户那里不是功能需求,是合规要求。客户的内审和安全评估会直接问:「员工离职后多久你们这边就用不了了?怎么证明?」答不上来会影响签单。
但这件事的难点在于:回收的链路天然是异步的。客户的 HR 系统里走完离职流程 → 他们的身份系统更新 → 我们的同步任务拉到 → 我们执行回收。每一环都有延迟,而合规要求的是一个确定的上界。
第一版有三个明显问题。
第一,账号被物理删除了。当时的逻辑是「离职就删」,看起来最干净。结果历史审批记录里的审批人显示为空,客户内审时问「这笔十万块的采购是谁批的」,我们答不上来——那个人的账号已经删了。审批记录在企业软件里有审计意义,审批人是记录的一部分,不能因为人离职就消失。所以改成停用而非删除,保留所有历史归属。
第二,只改了账号状态,没有失效会话。我们把账号标记为停用,但那个人浏览器里的会话还是有效的,一直到自然过期。也就是说离职后还有一个可用窗口,窗口长度等于会话有效期。客户安全评估直接指出了这一点。正确做法是主动失效:停用的同时把该用户的所有会话、刷新令牌、开放 API 凭据全部作废。「等它自然过期」在合规语境下不可接受。
第三,回收和「在途事务」纠缠在一起,导致回收被阻塞。那个人手上有五个待审批的单子、是三个定时报表的接收人、还有一批他创建的数据。第一版的实现是先处理完这些才停用,结果这些处理需要人工介入,停用被拖了好几天,等于回收失效。
所以关键设计是把回收拆成两段:
可立即执行的——停用登录、失效全部会话与令牌、撤销 API 凭据。这一段不依赖任何人工,必须秒级完成,它才是合规意义上的「回收」。
需要编排的——在途审批待办转交、数据资产移交、定时任务与订阅的接管。这一段进转交流程,可以慢,但不能阻塞上一段。
这个拆分是这个模块最重要的设计:它把「让他用不了」和「把他的工作交出去」这两件本来纠缠在一起的事分开,前者是安全底线且可自动化,后者是业务连续性且需要人。
最后是可证明。客户要的不只是「你做了回收」,而是「拿数据给我看」。所以要有回收时效埋点(记录每一步的时间戳:收到离职事件、执行停用、会话失效完成)和面向客户的回收报表,让他们的内审能自己核查。
把回收拆成「立即执行」和「需要编排」两段,这是这个模块的核心判断。直觉上会把离职处理当成一个完整的事务:待办转交完、资产移交完、然后停用。这个顺序很「干净」,但它让安全底线依赖于人工进度——第二段需要有人决定「转给谁」,而这个决定可能要等主管回复,一等就是几天。拆开之后,安全底线由自动化保证且秒级达成,业务连续性由流程慢慢处理,两者互不阻塞。取舍是会出现一个中间状态:账号已经不能登录,但他的待办还挂在他名下没转出去。这个中间状态是可接受的,因为待办卡单有独立的扫描机制会发现(见审批引擎那一页),而权限泄露没有补救。
停用而非删除,代价是数据留存与隐私之间的张力。保留账号是审计需要,但客户有时会援引数据保护法规要求「删除员工的个人数据」。我们的处理是区分「身份标识」和「个人信息」:审计必需的最小标识(内部账号 ID、姓名在记录中的快照)保留,非必需的个人信息(联系方式、头像、外部身份绑定)在离职后按客户配置的保留期清理。这个区分很重要——把「删除个人数据」和「删除审计记录」混为一谈,就会在合规和审计两边都出问题。
踩过的坑:离职即物理删除账号,历史审批记录的审批人变成空。客户内审时问「这笔十万块的采购是谁批的」,我们答不上来,因为那个人的账号已经删了,记录里只剩一个失效的外键。这在内审看来是很严重的问题——审批链不完整等于审批无效。修法是改为停用,并且在流转记录里存审批人姓名的快照(不只存 ID,防止未来任何原因导致 ID 无法解析)。教训是:有审计意义的记录必须自包含,不能依赖对其他表的关联查询,因为被关联的数据可能被删除、被修改、被脱敏。
踩过的坑二:只停用账号没失效会话,客户安全评估直接指出离职后仍有可用窗口。我们当时的理解是「账号停用了,下次鉴权就会失败」——但会话是已经签发的,鉴权时读的是会话而不是每次都查账号状态。修法两条:停用时主动失效该用户的全部会话与令牌;同时在鉴权链路上增加账号状态检查(带缓存,避免每次查库)。教训是:撤销权限时必须同时处理「已经签发的凭据」——改状态只影响未来的签发,已签发的凭据不会自己失效,这条在令牌、会话、API 密钥、预签名 URL 上都成立。
踩过的坑三:撤销漏了开放 API 凭据,人走了脚本还在跑。某个离职员工申请过一个 AccessKey 给他写的自动化脚本用,他离职后账号停用、会话失效,但那个 AccessKey 还在正常调用我们的开放 API 好几周,是做凭据盘点时才发现的。修法是把「该用户名下的所有凭据」纳入回收清单,并且定期盘点「归属于已停用账号的凭据」。教训是:权限回收要枚举「这个人拥有的所有访问路径」而不只是他的登录入口——登录只是其中一条路。
踩过的坑四:回收流程中某一步失败是静默的。会话失效那一步因为一个下游服务超时失败了,没有重试也没有告警,那个离职人员的会话继续有效到自然过期。修法是回收链路的每一步都要有重试与失败告警,并且失败当故障处理。教训是:安全相关的操作失败绝不能静默——普通业务失败可以等用户重试,安全操作失败意味着一个本该被关闭的通道还开着,而没有任何人在等它。
没做的部分:没做离职人员权限的自动继承推荐(根据继任者的岗位自动建议要转交哪些权限)。目前转交对象要人工指定,客户希望更自动化,但这需要可靠的岗位与职责数据,当时组织数据里没有。也没做跨系统的联动回收(同时通知客户的其他 SaaS),那超出了我们的边界。
离职到登录不可用的时长:这是这个模块最该报也最会被追问的数字。要分段报,因为链路是跨系统的:客户 HR 到客户身份系统(不由我们控制)、身份系统到我们同步拉到(受客户接口推送频率影响)、我们收到事件到执行完停用与会话失效(这一段完全由我们控制,是我们能承诺的部分)。只报总时长会被追问「哪段是你的责任」,分段报才显得想清楚了。
会话失效的验证:确定性验证——用一个已登录的会话,触发该用户离职,检查该会话立刻不可用(而不是等到过期)。同时验证刷新令牌不能续期、开放 API 凭据调用被拒。这三项要各有一条用例。
凭据盘点:报「归属于已停用账号的凭据数量」,这个数应该是 0,而且要定期盘点。诚实的表述是「曾发现过 N 个离职人员遗留的凭据,之后把凭据纳入回收清单并加了定期盘点」——说出发现过问题,比声称没有更可信。
历史可追溯:确定性验证——账号停用后,检查历史审批记录仍能显示审批人姓名、操作日志仍能显示操作人。这是内审必查项。
回收失败与重试:报回收链路各步骤的失败次数与重试成功情况。这个数字要主动报,因为它证明失败不再是静默的——我们踩过会话失效那一步静默失败的坑。
面向客户的回收报表:报是否提供了可供客户内审自行核查的报表。这本身就是一个交付物,比任何自述的数字都有说服力——客户要的不是「你说你做了」,而是「拿数据给我看」。
不要报「离职回收 100% 及时」或「零权限泄露」。链路跨系统,客户侧的延迟不由我们控制,绝对化断言在安全类话题上最容易被问穿,而且只要出一次就被推翻。正确表述是「我们负责的那一段耗时是多少(分段说清)、哪些回收项由确定性用例保证、凭据盘点的结果、失败如何重试与告警」——把责任边界和验证方式说清楚。
没有匹配的内容,换个关键词试试。
项目拆解 · 单点登录与组织同步(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据