4、租户身份标识:租户ID的设计、JWT中的租户声明、请求上下文中的租户传递
在多租户控制面架构中,租户身份标识是权限隔离的基石。每个请求必须携带明确的租户身份,系统才能判断该请求应访问哪些资源、执行哪些操作。本章围绕租户ID的设计原则、JWT中的租户声明结构以及请求上下文的传递机制展开,确保身份标识在微服务链路中不丢失、不被篡改。
4.1 租户ID的设计原则
租户ID(Tenant ID)是区分不同租户的唯一标识符。设计不当的租户ID会导致路由混乱、权限越界或性能瓶颈。以下是核心设计原则:
| 原则 | 说明 | 推荐做法 |
|---|---|---|
| 全局唯一 | 租户ID必须在整个控制面集群内唯一,不能跨租户重复 | 使用UUID v4或Snowflake算法生成 |
| 不可猜测 | 避免使用自增数字或可枚举的短字符串 | 采用随机字符串或哈希值,长度≥16字符 |
| 不可篡改 | 租户ID一旦分配,不应允许租户自行修改 | 由控制面统一分配,存储在租户注册表中 |
| 可路由 | 支持按租户ID进行数据分片或服务路由 | 设计时预留分区键位,如Snowflake中的工作节点ID |
| 可读性 | 在日志和审计中能快速识别租户归属 | 可选前缀+随机串,如 tnt_a3f8c2b1 |
4.2 JWT中的租户声明
JWT(JSON Web )是微服务间传递身份信息的标准载体。租户ID应作为JWT的私有声明(Private Claim)嵌入,而非放在公共声明中。标准结构如下:
{
"iss": "https://auth.controlplane.io",
"sub": "user_abc123",
"aud": "api-gateway",
"exp": 1718000000,
"iat": 1717996400,
"tenant_id": "tnt_a3f8c2b1", // 租户ID
"tenant_role": "admin", // 租户内角色
"tenant_features": ["audit", "backup"] // 租户特性标记
}
JWT中租户声明的关键设计要点:
- 声明名称标准化: 统一使用
tenant_id作为键名,避免各服务自行定义导致解析混乱。 - 签名保护: 租户声明必须包含在JWT的签名部分,防止中间人篡改。使用RS256或ES256非对称算法,确保服务间验证时不暴露私钥。
- 最小化声明: 只放入路由和权限判断必需的字段,避免携带大量租户元数据导致膨胀。元数据可通过租户ID查询缓存获取。
- 过期与刷新: 租户声明随JWT一起过期,建议有效期不超过15分钟,配合Refresh 实现长会话。
sub 或 iss 等公共声明中嵌入租户ID。公共声明有标准语义,混用会导致兼容性问题,且容易被解析库忽略签名验证。始终使用私有声明区域。
4.3 请求上下文中的租户传递
在微服务调用链中,JWT通常在网关层验证并解析,但后续服务间调用不可能每次都重新解析JWT。因此需要将租户ID从请求上下文(Context)中透传。以下是三种主流传递方式:
| 传递方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| gRPC Metadata | 服务间gRPC调用 | 原生支持、低延迟、自动传播 | 需手动注入拦截器 |
| HTTP Header | REST API调用 | 通用性强、调试方便 | 需防止客户端伪造 |
| 消息队列Header | 异步事件驱动 | 解耦、支持批量 | 需保证消息完整性 |
在gRPC场景中,推荐使用统一的客户端拦截器/中间件,从JWT中提取 tenant_id 并注入到 metadata 中:
// Go示例:gRPC客户端拦截器注入租户ID
func TenantUnaryClientInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
// 从当前context中提取租户ID
tenantID := ctx.Value("tenant_id").(string)
// 注入到gRPC metadata
md := metadata.Pairs("x-tenant-id", tenantID)
ctx = metadata.NewOutgoingContext(ctx, md)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
在HTTP场景中,网关验证JWT后,将租户ID写入 X-Tenant-ID 请求头,下游服务从请求头中读取。注意:网关必须剥离客户端传入的原始 X-Tenant-ID 头,防止租户伪造。
4.4 租户ID的传递安全与防篡改
租户ID在传递过程中面临的主要风险是中间人篡改和客户端伪造。以下是针对性的防护措施:
- 网关强制覆盖: 所有进入内网的请求,网关必须剥离客户端传入的
X-Tenant-ID头,重新从JWT中提取并注入。内网服务不信任任何来自客户端的租户ID。 - mTLS传输加密: 服务间调用启用双向TLS,防止中间人篡改请求头或Metadata。
- Context传播审计: 在关键服务入口记录租户ID的来源(JWT/Header/Metadata),便于审计链路是否被污染。
- 租户ID白名单: 对于敏感操作(如删除资源),服务端可二次校验租户ID是否在活跃租户白名单中,防止已删除租户的残留被滥用。
tenant_id 字段,配合 request_id 和 user_id,形成完整的请求链路追踪。推荐使用OpenTelemetry的Baggage机制,将租户ID自动传播到所有Span中,无需手动传递。
4.5 多级租户场景下的ID扩展
在复杂的企业级多租户架构中,可能存在组织(Organization)→ 项目(Project)→ 环境(Environment)的多级租户结构。此时租户ID需要支持层级表达:
| 层级 | 示例ID | 说明 |
|---|---|---|
| 组织级 | org_a3f8c2b1 |
顶级租户,拥有独立资源池和计费 |
| 项目级 | proj_d4e5f6a7 |
属于某个组织,共享部分资源 |
| 环境级 | env_b8c9d0e1 |
开发/测试/生产环境隔离 |
在JWT中,可以使用复合声明或嵌套对象表示层级关系:
{
"tenant_id": "org_a3f8c2b1",
"tenant_hierarchy": {
"org_id": "org_a3f8c2b1",
"project_id": "proj_d4e5f6a7",
"env_id": "env_b8c9d0e1"
},
"tenant_role": "project_admin"
}
权限判断时,服务端根据操作所需的层级粒度,从 tenant_hierarchy 中提取对应的ID进行校验。例如,创建Pod时校验 env_id,查看账单时校验 org_id。
4.6 租户ID的存储与缓存策略
租户ID不仅在请求中传递,还需要在服务端持久化存储和缓存,以支持高效的权限查询。以下是推荐策略:
- 数据库字段: 所有多租户表必须包含
tenant_id列,并建立联合索引(tenant_id + 业务主键)。 - Redis缓存: 以
tenant:{tenant_id}:features为键,缓存租户特性、配额、角色等元数据,TTL建议5分钟。 - 本地缓存: 对于高频读取的租户配置(如路由规则),使用Caffeine或Guava Cache做本地缓存,配合Redis发布订阅实现失效通知。
- 分片策略: 按租户ID哈希分片,确保同一租户的数据落在同一数据库分片,避免跨分片查询。