密钥权限隔离架构:全局密钥与租户专属密钥

这一章我们聊点实在的——密钥的隔离架构。

做过多租户系统的朋友应该都有体会:密钥管理这事儿,看着简单,真落地了全是坑。我记得第一次接触这个场景,是在一个SaaS平台上。客户要求每个租户的数据必须完全隔离,连加密密钥都不能共用。嗯,当时我就想,这不就是把密钥分分类,存到不同地方吗?结果真正动手才发现,远没那么简单。

说白了,密钥隔离的核心就两个问题:谁来用 和 放在哪。前者决定了密钥的归属,后者决定了密钥的安全边界。

全局密钥 vs 租户专属密钥

先说说最常见的两类密钥。

全局密钥,也叫系统密钥。整个平台共用一把。它通常用来加密一些非敏感数据,或者作为底层的基础密钥。比如,你可能会用它来加密配置文件里的数据库密码、第三方API的。

租户专属密钥,顾名思义,每个租户独享一把。这是数据隔离的核心手段。敏感数据,比如用户的个人资料、财务信息,都应该用租户自己的密钥来加密。

为什么要这样分?我经历过一个反面案例。有个项目初期为了省事,所有租户的数据全用一把全局密钥加密。后来有个大客户提出要独立部署,要求我们把他的数据导出来。结果你猜怎么着?数据是导出来了,但密钥没法跟着走——因为这是全局的,其他租户还在用。最后只能改代码、重新加密,折腾了整整两个迭代。你说这教训值不值?

所以,我现在的建议很明确:一开始就设计好密钥的归属。全局密钥只负责平台自身的元数据加密。租户数据,一律用租户专属密钥。

对比维度 全局密钥 租户专属密钥
使用范围 整个平台共享 单个租户独享
管理成本 低,一把搞定 高,需要按租户管理
安全隔离 弱,泄露即全局沦陷 强,一个租户出问题不影响其他
性能影响 快,无额外查找开销 略有损耗,需要根据租户ID定位密钥
典型场景 系统配置、非敏感元数据 用户财务数据、隐私信息

密钥空间隔离策略

密钥有了,怎么存?怎么找?这就涉及到密钥空间隔离。

核心思路:每个租户拥有独立的密钥命名空间,互相不可见、不可访问。

听起来像什么?其实就是一个巨大的哈希表,key 是租户ID,value 是该租户的所有密钥集合。这种做法我在好几个生产项目里用过,效果不错。

具体实现上,有两种常见策略:

  1. 物理隔离:每个租户独立的密钥存储实例。比如独立的数据库表、独立的Redis namespace。优点是隔离彻底,缺点是运维成本高。
  2. 逻辑隔离:共用一个存储,但通过前缀或标识来区分。比如密钥ID格式为 tenant_123:key_data。优点是管理简单,缺点是需要小心处理边缘情况。

我个人更倾向逻辑隔离+定期审计的组合。为什么呢?因为物理隔离听起来很酷,但真正维护几十上百个密钥存储实例的时候,你会发现监控、备份、恢复都成了噩梦。我曾经有段时间每天半夜被警报叫醒,就是因为某个租户的独立Redis实例内存打满了。

实战建议:先用逻辑隔离快速上线,后续再根据实际需要,对高安全级别的租户单独切到物理隔离。

下面这个图展示了整体架构,你可以直观感受一下密钥空间是怎么划分的:

多租户密钥空间隔离架构 全局密钥空间(系统级) 全局根密钥(GK) 平台签名密钥(SK) 数据加密密钥(DEK) 租户A专属密钥空间(tenant_a) A-加密密钥 A-签名密钥 A- 租户B专属密钥空间(tenant_b) B-加密密钥 B-签名密钥 B- 隔离原则 租户A无法看到租户B的密钥 访问控制矩阵强制约束

隔离策略的代码落地

光说不练假把式。我们来看看代码里怎么实现。

假设你用的是 Redis 做密钥存储。最简单的逻辑隔离,可以这样:

# 密钥命名空间常量
NAMESPACE_GLOBAL = "global"
NAMESPACE_TENANT_PREFIX = "tenant"

# 构建租户专属密钥的Redis Key
def build_tenant_key(tenant_id: str, key_name: str) -> str:
    return f"{NAMESPACE_TENANT_PREFIX}:{tenant_id}:{key_name}"

# 示例:获取租户A的加密密钥
tenant_a_enc_key = build_tenant_key("tenant_a", "encryption_key")
# 结果: tenant:tenant_a:encryption_key

# 全局密钥则不加租户标识
global_key = f"{NAMESPACE_GLOBAL}:root_key"
# 结果: global:root_key

注意:千万别在密钥ID里直接拼入明文租户名,容易导致路径遍历攻击。建议用租户的UUID或哈希值。

我曾经见过一个项目,密钥空间管理混乱到什么程度?有人把全局密钥和租户密钥放在同一个哈希表里,key格式还完全不统一。结果运维想清理一个离职租户的密钥时,误删了一把全局密钥。整个平台加密数据全部无法读取,回滚花了整整一天。你说这何必呢?

一种更安全的做法:密钥派生

你可能会问,每个租户都存一把独立的密钥,万一存储被脱库了呢?

这个问题问得好。真落到实战里,我们通常会再加一层保护——密钥派生。具体说,全局只存一把主密钥(Master Key)。每个租户的密钥,通过主密钥+租户特定信息(比如租户ID、盐值)派生出来。

import hashlib
import os

def derive_tenant_key(master_key: bytes, tenant_id: str, salt: bytes = None) -> bytes:
    # 如果外部没传盐,自动生成一个
    if salt is None:
        salt = os.urandom(16)
    # 使用HKDF或PBKDF2派生
    derived = hashlib.pbkdf2_hmac(
        'sha256',
        master_key,
        tenant_id.encode() + salt,
        iterations=100000
    )
    return derived, salt

# 存储时,只需要保存盐值即可
# master_key 放在硬件安全模块或单独的密钥管理系统
master = b"this_is_a_32_byte_master_key_xxxx"
tenant_key, salt = derive_tenant_key(master, "tenant_a")
# tenant_key 用来加密租户数据
# salt 可以明文存储在数据库里

这种方案的好处很明显:即使数据库泄露,黑客拿到的也只是派生密钥或盐值,没有主密钥,依然无法破解其他租户的数据。

关键原则:主密钥永远不能落盘。放在内存、硬件安全模块(HSM)或云端密钥管理服务(KMS)里。

嗯,这里要注意一下。派生方案虽然安全,但性能上会有一定消耗。每次加解密都要走一遍派生计算。我建议在应用启动时或租户首次访问时,把派生出来的密钥缓存在本地内存里,设置一个合理的过期时间。这样既保证了安全,又不影响性能。