字段级隔离(tenant_id)在GORM中极易因漏加条件导致P0数据泄露,必须强制DAO方法签名显式接收tenantID string、全程硬编码拼入Where、Preload/Joins手动加租户条件、数据库层设NOT NULL+联合索引+RLS策略,并确保日志与缓存带租户上下文。

字段级隔离(tenant_id)在 GORM 中极易因漏加条件导致 P0 数据泄露,不能靠 Scopes 或 context 透传“兜底”——GORM 的 Count()、Preload()、Raw()、Unscoped() 全部绕过自定义 scope,必须从 DAO 层强制注入且全程不可绕过。
DAO 方法签名必须显式接收 tenantID string
把租户 ID 塞进 context.Context 再一路断言取值,等于把安全责任甩给每个调用方:类型断言失败会 panic,异步任务里 ctx 可能为空,测试也难覆盖真实 SQL 漏条件场景。
- 所有 DAO 函数(如
FindUserByID、CreateOrder)签名必须带tenantID string参数,禁止从ctx.Value获取 - 内部直接拼入
Where("tenant_id = ?", tenantID),不依赖任何中间层封装 - 软删除字段(如
deleted_at)必须和tenant_id同级约束:Where("tenant_id = ? AND deleted_at IS NULL", tenantID),否则Unscoped().Where()可绕过租户检查
Preload 和关联查询必须手动加租户条件
GORM 不会把主查询的 scope 透传进 Preload,这是最常被忽略的越权点。调用 db.Preload("Orders").First(&user) 默认加载全库订单,而非当前租户。
- 每个
Preload都要显式指定带租户条件的子查询:Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) }) -
Joins同理,需在Joins("JOIN orders ON ... AND orders.tenant_id = ?")中硬编码参数占位符 - 避免使用
Preload("Orders")这类裸调用,可封装成PreloadTenantOrders(tenantID)并统一校验
数据库必须强制 tenant_id 非空 + 联合索引
代码层写了 WHERE tenant_id = ? 不代表绝对安全——一旦某次查询跳过 ORM(比如用 Raw() 或直连 *sql.DB),就直接暴露全量数据。
立即学习“go语言免费学习笔记(深入)”;
- 建表时强制
tenant_id VARCHAR(32) NOT NULL,并设CHECK (tenant_id != '')(PostgreSQL)或NOT NULL(MySQL) - 每张业务表至少加一个联合索引,如
(tenant_id, email)或(tenant_id, created_at),否则单靠tenant_id索引查得慢,延迟秒级起跳 - PostgreSQL 可进一步启用行级安全策略(RLS):
CREATE POLICY tenant_isolation ON users FOR ALL USING (tenant_id = current_setting('app.current_tenant')),作为最后一道防线
日志与缓存必须带租户上下文
排查问题时若日志里没有 tenant_id 字段,根本无法判断是哪个租户的数据异常;缓存键若没带租户前缀,不同租户可能读到彼此的旧数据。
- 所有日志打点必须显式写入
tenant_id字段:log.WithField("tenant_id", tenantID).Info("user created") - 缓存 key 必须包含租户标识:
cache.Get("user:123:" + tenantID),禁用全局共享 key 如"user:123" - Redis 或 Memcached 的连接池无需按租户隔离,但 key 命名空间必须严格区分
真正危险的不是“怎么加 tenant_id”,而是“哪一次没加”。字段隔离方案下,只要漏一次 Where、一次 Preload 条件、一次缓存 key 前缀,就等于把数据裸奔在公网。它只适合租户数 Raw、Unscoped、Count 这几类高危方法。


















