Rust 多租户系统最容易漏的不是权限,是数据边界
用户已经登录,角色也对,但接口少带了一个
tenant_id,结果查到了别的租户数据。
多租户系统里,认证授权只是入口。
真正容易出问题的是数据边界:查询是否带租户条件,缓存 key 是否隔离,后台任务是否按租户执行,消息事件是否携带租户信息,审计日志能不能追到租户。
多租户不是给表加一列 tenant_id,而是让租户边界贯穿所有读写路径。
tenant_id 要进入类型系统
不要让租户只是一个到处传的 u64。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TenantId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct OrderId(u64);这些小类型看起来啰嗦,但它们能阻止很多低级错误。
更进一步,把资源放进租户作用域里:
#[derive(Debug, Clone, Copy)]
struct TenantScoped<T> {
tenant_id: TenantId,
value: T,
}
type TenantOrderId = TenantScoped<OrderId>;函数签名一旦要求 TenantOrderId,调用方就不能只传一个裸订单 id。
多租户系统里,租户上下文应该是类型,不应该是口头约定。
查询必须默认带租户条件
多租户表通常长这样:
create table orders (
tenant_id bigint not null,
id bigint not null,
status text not null,
primary key (tenant_id, id)
);查询时不要只按 id:
select *
from orders
where tenant_id = $1
and id = $2;Rust 侧可以让 repository 接口强制带租户:
#[derive(Debug)]
struct OrderQuery {
tenant_id: TenantId,
order_id: OrderId,
}
fn order_cache_key(query: &OrderQuery) -> String {
format!("tenant:{}:order:{}", query.tenant_id.0, query.order_id.0)
}这不是为了缓存 key,而是展示一个边界:所有资源访问都应该知道自己属于哪个租户。
缓存 key 少了租户就是数据泄漏
缓存是多租户系统里的高风险点。
错误 key:
order:1001正确方向:
tenant:42:order:1001用函数集中生成:
fn tenant_user_key(tenant_id: TenantId, user_id: UserId) -> String {
format!("tenant:{}:user:{}", tenant_id.0, user_id.0)
}不要让业务代码手写缓存 key。
缓存 key 一旦散落,迟早有人漏掉租户维度。多租户数据泄漏通常不需要复杂攻击,一个错误缓存 key 就够了。
后台任务也要带租户上下文
很多系统的 HTTP 接口有租户校验,后台任务却没有。
比如批量同步订单:
#[derive(Debug)]
struct TenantJob {
tenant_id: TenantId,
kind: JobKind,
}
#[derive(Debug)]
enum JobKind {
SyncOrders,
RebuildSearchIndex,
}任务 payload 必须带租户。worker 执行时不能自己猜,也不能从某个全局配置里取默认租户。
后台任务、消息消费者、定时任务都应该遵守同一条规则:
只要访问租户数据,就必须有明确租户上下文。
事件也要带租户信息
事件驱动系统里,如果事件没有租户信息,消费者会很难做隔离。
#[derive(Debug)]
struct TenantEvent<E> {
tenant_id: TenantId,
event_id: String,
payload: E,
}
#[derive(Debug)]
struct OrderCreated {
order_id: OrderId,
}租户信息不要只藏在 payload 某个字段里。它应该是事件信封的一部分,方便路由、权限校验、审计和指标聚合。
如果事件进入共享 MQ,租户字段就更重要。
超级管理员也不能绕过边界
后台系统常见一个危险设计:管理员接口不带租户条件。
理由是“管理员可以看所有租户”。
这个理由不成立。能看所有租户,不代表查询可以不带边界。
更合理的是显式表达作用域:
#[derive(Debug, Clone, Copy)]
enum TenantScope {
One(TenantId),
All,
}
fn can_use_all_scope(is_super_admin: bool) -> bool {
is_super_admin
}All 应该是显式选择,而不是默认行为。
审计日志里也要记录这次操作作用于单个租户,还是跨全部租户。
超级管理员可以有更大权限,但不能让数据边界消失。
结论
多租户系统的风险不只在登录和角色,而在每一条数据路径是否带着租户边界。
tenant_id要进入类型系统;数据库主键、查询条件、缓存 key、后台任务、事件信封、审计日志都要带租户上下文;跨租户操作必须显式表达,不要让 All 成为默认。
多租户做得好,用户之间互不打扰;做得差,一个漏掉的条件就可能变成数据泄漏。