单库单用户不等于多租户安全,因其仅实现连接级隔离,无法防止应用层漏写 tenant_id 导致的行级数据越权访问;需结合rls、视图、orm层强制过滤及租户上下文穿透等多重机制。
为什么单库单用户不等于多租户安全
单库单用户只是物理隔离的起点,不是权限隔离的终点。mysql 或 postgresql 里一个 create user 配上 grant,默认只控制「能不能连」和「能不能查某张表」,但租户数据混存在同一张表(比如带 tenant_id 字段)时,db 层根本不知道哪行属于谁——应用层漏写 where tenant_id = ?,数据就直接裸奔。
常见错误现象:SELECT * FROM orders 在后台脚本或管理接口中被无条件执行;审计日志显示跨租户查询成功;DBA 发现某租户能通过 JOIN 关联出其他租户的 user_profiles 数据。
- 必须区分「连接级隔离」(单用户)和「行级隔离」(逻辑租户边界),后者靠应用逻辑或 RLS 实现,DB 用户权限管不了
- PostgreSQL 的
ROW LEVEL SECURITY可以补位,但 MySQL 8.0+ 的SQL SECURITY DEFINER+ 视图只能覆盖部分场景,不能替代应用层校验 - 如果用 ORM(如 Django ORM、TypeORM),
tenant_id过滤必须下沉到 QuerySet 或 Repository 层,不能靠每个开发手写.where({ tenant_id })
如何用视图 + 权限组合实现最小权限交付
给每个租户建独立 DB 用户没意义,但可以为每个租户配一套只读/读写视图,并把权限精确切到这些视图上。这样即使应用误连错用户,也拿不到原始表。
使用场景:SaaS 后台需提供租户自助 SQL 查询入口,或 BI 工具直连数据库;需要避免 DBA 误操作影响生产租户。
- 先建视图:
CREATE VIEW v_tenant_orders AS SELECT * FROM orders WHERE tenant_id = current_setting('app.tenant_id', true)(PostgreSQL);MySQL 需用DEFINER+ 带参数的存储过程模拟,但不推荐 - 收回租户用户对基表的
SELECT权限:REVOKE SELECT ON orders FROM tenant_123 - 只授视图权限:
GRANT SELECT ON v_tenant_orders TO tenant_123 - 连接时必须设置会话变量:
SET app.tenant_id = 't-789',否则视图返回空——这是保护机制,也是容易漏掉的启动步骤
RLS(行级安全策略)在 PostgreSQL 中的实际约束点
PostgreSQL 的 ROW LEVEL SECURITY 是目前最接近「自动加租户过滤」的方案,但它不是开关一开就万事大吉。
性能影响:开启 RLS 后,所有对该表的查询都会被重写,即使你手动写了 WHERE tenant_id = 'x',也会叠加策略谓词,可能干扰索引选择;全表扫描风险上升。
- 必须为
tenant_id字段建索引,且类型要和策略中使用的完全一致(比如策略用text,字段就不能是varchar(32)而不加 COLLATE) - 策略函数里禁止调用非
STABLE函数(如now()、current_user),否则 DML 会被拒绝;要用current_setting('app.tenant_id')这类稳定函数 -
BYPASSRLS权限不能下放给应用用户,否则策略形同虚设;DBA 账号才该有它 - 注意触发器和规则(RULE)不遵守 RLS,DDL 操作(如
TRUNCATE)也不受控——这些仍是盲区
应用层必须守住的最后一道防线
数据库再严,只要应用拼 SQL、用原始查询、或 ORM 允许绕过 scope,就等于把钥匙塞进租户手里。真正防住跨租户访问的,是框架层的强制租户上下文绑定。
可给出简短示例:Express + Knex 中,在中间件里解析请求中的 X-Tenant-ID,然后注入到事务上下文中;后续所有查询都自动带上 .where('tenant_id', ctx.tenantId)。
- 不要信任任何来自客户端的
tenant_id,必须从认证凭证(JWT payload、session、API key 绑定关系)中提取 - 避免在 DAO 层写「通用查询函数」接受任意
where对象,这种设计天然绕过租户隔离 - 单元测试必须覆盖「未设置租户上下文时是否抛异常」「伪造
X-Tenant-ID是否被拒绝」两个 case - 日志里记录每次 DB 查询实际生效的
tenant_id,而不是仅记请求头——防止中间件失效后无声越权
复杂点在于租户上下文如何穿透异步链路(比如消息队列消费、定时任务),这时候靠线程局部存储(Node.js 的 AsyncLocalStorage、Java 的 ThreadLocal)比传参更可靠,但也更容易被忽略。










