返回文章列表

Rust 多租户系统最容易漏的不是权限,是数据边界

178·2 分钟阅读
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 成为默认。

多租户做得好,用户之间互不打扰;做得差,一个漏掉的条件就可能变成数据泄漏。