4.3 安全边界:越权访问与审计日志

场景:客户的安全部门比客户更难缠

签下一个集团客户时,流程到了对方安全部门那里被卡了两周。对方提出的问题清单有四十七条,核心只有一条:如果你们某个程序员写错一行代码,我们全公司的数据会不会被另一个客户看到?怎么证明?我们交出的答案不是 PPT,而是一套已经跑通的越权检测与审计体系——每天自动跑越权矩阵、所有敏感操作留痕、日志按租户可隔离可导出。对方看完演示后的评价是「比我们见过的多数私有化厂商还细」,这单就这么签下来了。

安全这件事在 SaaS 里有个尴尬的位置:它不产生功能价值,客户买之前不在意,出事之后它是唯一重要的事。我们已经把它当成续约的隐形筹码——没有任何客户会因为审计日志做得好而签约,但确实有客户因为它而没在续约时压价。而且多租户的安全边界天然比私有部署险——所有租户共享同一套代码与运维团队,一次越权事故伤害的不是一家客户,而是「所有客户对我们的信任」,这种连带伤害在私有化部署里是不存在的。所以我们的态度是:把安全当产品做,并且做给客户的安全部门看。

审计日志则是安全的底座。我们的定义是:审计日志回答「谁在什么时间对谁的数据做了什么」,它不是为了好看,是为了在出事后的两小时内还原现场。下面的所有设计都围绕这个目标。

审计日志的形态我们对比过三种:

方案 可追溯度 检索成本 我们的采用
写本地文件按日归档 中 高 弃用
写业务库审计表 高 中 兜底用
统一进 CLS 检索 高 低 现在方案

配置:审计写入与越权检测

审计写入做成统一门面,业务代码只调一个方法,字段由门面补齐,避免各处手填漏字段:

class Audit
{
    public function log(string $action, array $detail = [])
    {
        $ctx  = app(TenantContext::class);
        $user = request()?->user();

        $this->cls->put([
            'tenant_id' => $ctx->id(),
            'user_id'   => $user?->id ?? 0,
            'action'    => $action,
            'resource'  => $detail['resource'] ?? '',
            'ip'        => request()?->ip(),
            'ua'        => request()?->userAgent(),
            'ts'        => time(),
        ]);
    }
}

日志统一进 CLS,检索时按租户 ID 过滤,这就是 5.2 会展开的按户检索。审计字段里最容易被偷懒的是租户号——不写,日志就没有归属,出事时根本没法定位影响范围。我们做了强制校验:缺少租户号的审计写入直接抛异常,宁可日志写不进去,也不允许一条无主日志存在。

还有一个细节是日志的时区。多租户系统的客户可能分布在不同时区,审计日志统一存 UTC 时间戳,前端展示时按客户设置的时区转换。这个细节看起来不起眼,但出事举证的时候,客户拿着自己系统的本地时间来对账,时区错了半天的日志就成了罗生门,我们吃过一次这种哑巴亏。

演进:三代审计体系

代际 形态 触发升级的事件
第一代 本地文件按日切割 取证时翻日志翻通宵
第二代 业务库审计表 保留期不够且检索慢
第三代 CLS 统一检索加脱敏 明文密码进日志事故

第三代的关键升级不只是存储位置,而是「日志可被产品化使用」:客户自己能按户检索、能导出、能设告警。审计日志从内部工具变成了对外能力,这个转变也直接提升了几个大客户的续约谈话氛围——他们终于不用每次都来问我们要取证材料了。

越权检测则是自动化防线,核心是一个越权矩阵:准备若干对测试账号,每晚跑一遍交叉访问尝试:

检测维度 数据 页面 接口
普通员工 只读授权项目 工作台 限本户
跨户尝试 四零三 四零三 四零三
管理员越权 记录告警 记录告警 记录告警

互为检验的两层,才算守住了边界。有个形象的内部比喻:中间件是门上的锁,越权矩阵是每晚来试锁的人,锁没锁只有试过才知道——这个比喻让新来的运维同学一个礼拜就理解了整套体系的用意,比讲十页文档管用。

任何一格返回了非四零三的结果就告警,当天值班的人必须确认,确认记录本身也会留痕,形成一条「检测、确认、处置」的完整链路。这套矩阵与 3.1 权限体系的中间件是硬币的两面:中间件挡在线上,矩阵在线下持续验证它真的在挡。互为检验的两层,才算守住了边界。

量化:审计与越权的三个指标

指标 采集 标准
敏感操作留痕率 日志覆盖点抽查 百分之百
越权检测每日全绿 自动化矩阵 连续达标
日志可导出客户比例 功能使用统计 七成以上

第一项的抽查方式有讲究:我们用投毒式测试。安全同学在测试租户里造一条敏感操作,看一周内它是否出现在导出样本里,出现即合格,没出现就说明某个写入点漏了。这种暗访式检查比数日志条数有用得多——它检查的是「真正该被记下的操作一条都没漏」,而不是「日志总量是否达标」。

第三项是我们的差异化做法:客户的安全部门可以随时导出本户的审计日志自行留存。许多 SaaS 不愿意给这个能力,怕客户拿去做别的分析。我们的判断相反——日志让客户自己留着,他反而更放心把数据放上来,这是用透明换信任的又一次实践。有好几个客户的合规年审,就是靠这个导出功能过的,其中一家的审计师甚至在报告里把这套机制写成了推荐项。

踩坑:安全边界上的四个雷

第一个是 GET 请求不改数据所以不审计。错。导出、下载链接、查询敏感字段全在 GET 里,我们补齐之后审计量翻了三倍,但漏掉的正是最敏感的操作。当时同事开了句玩笑,说 GET 接口是安全体系里的盲区也是事故的高发区,后来证明这话一点不夸张。

第二个是审计日志的保留期。一开始只存三个月,客户在半年后提起一次内部纠纷要求取证,日志已经没了。之后保障期延长到一年,冷数据转 COS 归档降成本,合规与成本可以兼得。这次的教训被简化成一句话写进团队守则:日志的保留期不是由存储成本决定的,而是由客户的纠纷周期决定的。

第三个是日志里的敏感信息脱敏。早期日志里原文记录了请求参数,某次导出里出现了明文密码(第三方对接系统用 GET 传的)。加脱敏中间件后,所有 password、token、secret 字段一律打码,违规字段写入即告警。这件事发生之后我们顺势加了一条规则:审计日志的样本要每月由安全负责人随机抽检十条,抽检内容与脱敏规则对账,防止规则老化——新字段新参数不断出现,脱敏名单也必须更新,这是一项需要持续维护的资产而非一次性的工作。

第四个是共享账号问题。不少租户多人共用一个管理员账号,审计只能定位到账号定位不到人。我们没有强禁(客户会骂),而是在账号下做二次身份选择,记录操作时的实际操作人,用产品设计替代制度要求。上线后审计表格里多了一列「实际操作人」,客户的财务部门对此非常满意,因为他们终于能在内部罚款时找到具体的人了。

审计字段命名上还有一点经验:字段名要用业务语义而不是技术语义。早期我们记的是 route 与 controller,客户的审计同事看不懂;后来统一改成「模块、动作、对象」三段式(比如 导出-订单-八月份全部),审计日志立刻从给程序员看的东西变成了给法务看的东西,沟通成本降了一个量级。而且检索时还能跨租户做聚合分析,发现潜在的攻击模式,这是单户视角看不到的额外红利。

小结

安全边界的守护靠两件事:权限校验挡在线上(3.1),审计与检测在事后兜底(本节)。前者决定事故尽量不发,后者决定发了能查清、能证明、能改进,这两句加在一起,就是客户安全部门买单的理由。把审计日志当产品能力开放给客户,看似让渡了控制感,实际换来的是更深的信任——这比任何安全认证都更接近客户安全部门真正想要的东西。下一节看限流分级,那是流量洪峰来时的最后一道闸。再补一条审计与业务联动的经验:审计日志只在事后看就浪费了一半价值。我们把审计里的高频导出行为做成了实时风控信号——某户管理员深夜连续导出三个月订单,触发警报并临时降级其导出权限。这起事件后来查明是离职员工的异常行为,客户 IT 负责人来电致谢时说,他们自建系统从来没有这种主动性。审计从「出事查证据」升级为「出事前先响」,是审计体系最高级的一层用法。让审计日志不止于合规留痕,多租户平台还能顺手卖出安全增值服务的故事,这个潜力往往被低估。具体的风控规则落地,可以与 4.4 限流分级的配额思想结合起来读:一个是防恶意,一个是防过载,手段不同但骨架相同。

延伸阅读