真正可落地的方案只有两条路:字段隔离必须全程强制注入tenant_id,或schema隔离必须为每个租户缓存专属*sql.DB实例并设置search_path;选错路径或落地不严,安全就只剩祈祷。

Go 语言构建多租户 SaaS 系统,底层项目不能靠“加个中间件+塞 context 就完事”——漏一次 WHERE tenant_id = ? 或绕过一次 Preload 过滤,就是 P0 数据泄露。真正可落地的方案只有两条路:字段隔离(tenant_id)必须全程强制注入,或 schema 隔离(PostgreSQL search_path)必须为每个租户缓存专属 *sql.DB 实例。选错路径或落地不严,安全就只剩祈祷。
为什么 Casbin 的默认 RBAC 模型在多租户下会越权
Casbin 的 rbac_model.conf 默认只定义三元组 r = sub, obj, act,所有租户共享同一策略空间。这意味着 alice 在租户 A 被赋予 admin 角色后,调用 e.Enforce("alice", "/api/users", "read") 时,Casbin 完全不知道该查哪个租户的权限——它会匹配到任意租户下的 p, admin, /api/users, read 行。
必须改模型为四元组:
[request_definition] r = sub, tenant, obj, act <p>[policy_definition] p = sub, tenant, obj, act</p><p>[role<em>definition] g = </em>, <em>, </em></p><p>[policy_effect] e = some(where (p.eft == allow))</p><p>[matchers] m = r.sub == p.sub && r.tenant == p.tenant && r.obj == p.obj && r.act == p.act</p>
调用时也必须传四参数:e.Enforce("alice", "tenant-abc", "/api/users", "read");策略行要显式带租户:p, admin, tenant-abc, /api/users, read;角色继承也要带:g, alice, admin, tenant-abc。
别为每个租户启一个 Enforcer 实例——内存爆炸、策略同步难;用单实例 + FilteredAdapter 加载租户子集更可控。
GORM 的 Preload 和 Count 为什么总绕过 tenant_id 过滤
Preload("Orders") 和 Count() 不继承主查询的 Scopes,GORM v2+ 也不会自动把 tenant_id 透传进关联查询或聚合语句。裸写 db.Preload("Orders").Find(&u),结果是查出用户 u 的所有订单——跨租户的。
- 禁止裸调
db.Preload("Orders").Find(&u);改用封装方法:tenantDB.User().WithOrders(tenantID).Find(&u),内部对Orders关联也手动加WHERE tenant_id = ? -
Count()必须走同级过滤:db.Where("tenant_id = ? AND status = ?", tenantID, "pending").Model(&Order{}).Count(&count),不能依赖 scope 透传 - 软删除必须和
tenant_id同级约束:Where("tenant_id = ? AND deleted_at IS NULL", tenantID),否则Unscoped()会直接绕过 - 每张表的
tenant_id字段必须加联合索引,如(tenant_id, email)或(tenant_id, created_at),否则分页查慢成秒级延迟
PostgreSQL schema 隔离怎么避免连接池失效
SET search_path TO tenant_abc 是 session 级命令,但 *sql.DB 连接池不保证复用同一物理连接。执行完归还,下次取到的连接仍是默认 public schema。
正确做法是用 sync.Map 缓存租户专属 *sql.DB 实例:
var tenantDBs sync.Map // key: string, value: *sql.DB
<p>func GetTenantDB(tenantID string) (<em>sql.DB, error) {
if db, ok := tenantDBs.Load(tenantID); ok {
return db.(</em>sql.DB), nil
}
dsn := "host=pg port=5432 user=app password=xxx dbname=myapp sslmode=require"
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
_, err = db.Exec("SET search_path TO " + tenantID)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
tenantDBs.Store(tenantID, db)
return db, nil
}</p>
关键点:
- 每次初始化后立刻执行
db.Exec("SET search_path TO " + tenantID),后续所有操作自动落在该 schema - 必须调
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute),否则 1000 个租户 × 默认 100 连接 = 打崩 PostgreSQL - 别手写
"tenant_abc.users"拼表名——破坏 ORM 抽象、难测试、无法用 GORM 钩子 - 缓存 key 推荐用子域名(如
acme.example.com → acme),比 UUID 更易排查
DAO 层函数签名为什么必须显式接收 tenantID string
把 tenantID 塞进 context.Context 再一路传到 DAO 层,等于把隔离责任甩给每个业务开发者。类型断言失败会 panic,异步任务(如 time.AfterFunc)里还可能丢失值。
真正兜底的做法是让函数签名本身拒绝“不带租户”的调用:
- 所有 DAO 函数必须显式接收
tenantID string参数,例如:func (s *UserService) GetUserByID(tenantID string, id uint) (*User, error) - 禁用全局变量和
context.Value透传——它们不可靠、不可审计、不可测试 - 禁用裸
db.Where().Find();改用Scopes(ByTenant(tenantID))收口过滤逻辑 - 哪怕看起来“没必要”的单条查询(如
GetUserByID)也要加WHERE tenant_id = ?——因为 ID 在不同租户下可能重复
最常被忽略的点是:schema 隔离虽能兜底防越权,但 GORM 迁移工具(如 golang-migrate)不支持按 schema 批量执行,你得自己遍历租户列表挨个跑迁移命令——这点在上线前必须验证清楚。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











