触发器不能替代租户上下文绑定,仅是靠近数据落盘的校验防线;应用层须显式设置租户变量,连接池需支持会话清理;postgresql中应使用current_setting('app.tenant_id', true)::uuid安全读取并校验。

触发器不能替代租户上下文绑定,也不能兜底权限漏洞——它只是一道靠近数据落盘的校验防线。真正起效的前提是:应用层必须在每次连接/事务开始前显式设置租户变量,且连接池必须支持并正确配置会话清理。
PostgreSQL 触发器里怎么安全读取 current_setting('app.tenant_id')
触发器函数内部不能自动感知租户,current_setting() 是唯一可靠入口,但必须带 true 参数,否则未设值时直接报错中断事务:
-
current_setting('app.tenant_id', true)返回NULL而非抛异常,后续必须显式判空 - 别用
CURRENT_USER或SESSION_USER替代——它们是数据库角色名,和业务租户无关 - 若字段类型是
UUID,而current_setting()返回TEXT,需强制转换:NEW.tenant_id := current_setting('app.tenant_id', true)::UUID - 确保
app.tenant_id已在postgresql.conf的custom_variable_classes中注册,否则会报 “unrecognized configuration parameter”
INSERT/UPDATE 时如何防止 tenant_id 被绕过或篡改
核心不是“填充”,而是“校验+拦截”。绕过应用直连执行 UPDATE orders SET tenant_id = 't456' 是最常见越权路径,触发器必须堵住:
- INSERT:用
BEFORE INSERT填充 + 非空校验,字段允许 NULL 时尤其要防覆盖;若列定义为NOT NULL且无默认值,不赋值会直接报错 - UPDATE:必须比对
NEW.tenant_id IS DISTINCT FROM OLD.tenant_id,不能用!=—— 否则任一为NULL时条件恒为NULL,校验失效 - 别在 UPDATE 触发器里无条件重写
NEW.tenant_id := OLD.tenant_id,否则业务合法更新其他字段(如status)时,tenant_id会被静默重置 - MySQL 用户注意:
:=是赋值操作符,=是比较,写成SET NEW.tenant_id = @tenant_id实际是判断而非赋值,会静默失败
DELETE 操作为什么必须校验 OLD.tenant_id
DELETE 没有 NEW 行,但攻击者常构造 DELETE FROM orders WHERE id = 123 并省略 tenant_id 条件——只要表没开 RLS,这条语句就能删掉任意租户的数据:
- 必须在
BEFORE DELETE触发器中检查OLD.tenant_id IS DISTINCT FROM current_setting('app.tenant_id', true) -
OLD.tenant_id总是存在(来自已存在的行),所以无需判空,但必须用IS DISTINCT FROM,否则NULL = NULL返回FALSE,导致校验跳过 - 触发器内直接
RAISE EXCEPTION,事务立刻回滚,DELETE 失败;不要试图重写WHERE条件——触发器做不到 - 如果用 PgBouncer,必须启用
server_reset_query = 'DISCARD ALL'或至少RESET app.tenant_id,否则连接复用时上个租户的SET会残留
连接池和索引这些“看不见”的环节最容易翻车
触发器逻辑再严密,一旦连接池配置错、索引没建对、或租户变量没清空,整个隔离就形同虚设:
- PgBouncer 必须用
transaction池化模式,session模式下SET不生效;HikariCP 等需配connection-init-sql = RESET app.tenant_id - 所有含
tenant_id的表,都得建(tenant_id, id)或(tenant_id, created_at)复合索引,否则按租户查就是全表扫 - 字段名、类型、是否允许 NULL 必须全局统一,否则触发器无法复用,
tenant_id TEXT和tenant_id UUID混用会导致隐式转换失败 - 日志里出现“本该查租户 A 却返回租户 B 的数据”,90% 是连接池残留变量,不是触发器写错了











