这是一个非常明智的选择。使用 PostgreSQL 的 RLS (Row Level Security) 可以将复杂的权限判断逻辑从应用层(Go 代码)剥离,下沉到数据库层。
这样做的好处是:
- 应用层代码极简 :业务代码只需写
db.Find(&users),不需要手动拼 WHERE 条件。 - 防止越权:即使开发者忘记在代码里加权限判断,数据库也会自动拦截。
- 性能优化:利用 PG 的索引和原生特性处理层级关系,比 Go 查出数组再 IN 查询要快。
以下是基于 PG RLS 重新设计的模型方案。
1. 核心设计思路
为了配合 RLS,我们需要在数据库结构上做一点微调,主要是引入 TreePath (路径字段)。
- 原理 :不再在代码里递归查询"子部门",而是在数据保存时维护路径(如
1.5.20)。 - RLS 判定 :利用 PG 的
ltree扩展或字符串匹配,一行 SQL 解决"本公司及下级"的判断。
2. 重新设计的模型
我们删除复杂的逻辑字段,保留核心结构字段,并增加 TreePath。
go
package models
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
)
// Ltree 类型适配 (用于处理树形路径)
type Ltree string
func (l *Ltree) Scan(value interface{}) error {
if value == nil {
*l = ""
return nil
}
switch v := value.(type) {
case []byte:
*l = Ltree(v)
case string:
*l = Ltree(v)
default:
return errors.New("cannot scan Ltree")
}
return nil
}
func (l Ltree) Value() (driver.Value, error) {
return string(l), nil
}
// 租户表
type Tenant struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"size:100"`
Status int8 `gorm:"default:0"`
CreatedAt time.Time
}
func (Tenant) TableName() string { return "sys_tenant" }
// 公司表 (核心变更:增加 TreePath)
type Company struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"not null;index"`
ParentID int64 `gorm:"default:0"`
// 使用 Ltree 类型存储路径,例如: "1.5.10" 表示根节点1下的5下的10
TreePath Ltree `gorm:"type:ltree;not null;index"`
Name string `gorm:"size:100"`
Status int8 `gorm:"default:0"`
CreatedAt time.Time
}
func (Company) TableName() string { return "sys_company" }
// 部门表 (核心变更:增加 TreePath)
type Department struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"not null;index"`
CompanyID int64 `gorm:"not null"`
ParentID int64 `gorm:"default:0"`
TreePath Ltree `gorm:"type:ltree;not null;index"` // 例如: "1.5.20.33"
Name string `gorm:"size:100"`
Status int8 `gorm:"default:0"`
CreatedAt time.Time
}
func (Department) TableName() string { return "sys_department" }
// 用户表
type User struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"not null;index"`
CompanyID int64 `gorm:"not null"`
DeptID int64 `gorm:"not null"`
// 简化权限字段:仅保留标志位,具体的 DataScope 逻辑由 RLS Policy 内部处理
// 或者保留 DataScope 传递给 RLS
IsSuperAdmin bool `gorm:"default:false"` // 平台超管
IsTenantAdmin bool `gorm:"default:false"` // 租户超管
DataScope int8 `gorm:"default:4"` // 1:全部 2:公司 3:部门 4:本人
Username string `gorm:"size:50"`
Status int8 `gorm:"default:0"`
CreatedAt time.Time
}
func (User) TableName() string { return "sys_user" }
// 角色表 (不再承担复杂的 SQL 拼接逻辑,仅做配置存储)
type Role struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
Name string `gorm:"size:50"`
DataScope int8 `gorm:"default:4"` // 1:全部 2:公司 3:部门 4:本人
Status int8 `gorm:"default:0"`
}
// 用户角色关联
type UserRole struct {
UserID int64 `gorm:"primaryKey"`
RoleID int64 `gorm:"primaryKey"`
}
3. RLS 核心逻辑
这是方案的灵魂。我们需要在数据库层面定义策略。
第一步:创建扩展并设置会话变量
在每次请求开始时(中间件),将当前用户的上下文注入到 PG 会话中:
sql
-- 开启 ltree 扩展 (只需执行一次)
CREATE EXTENSION IF NOT EXISTS ltree;
-- 中间件执行的 SQL (Go 伪代码)
-- SET LOCAL app.current_tenant_id = '1001';
-- SET LOCAL app.current_user_id = '2001';
-- SET LOCAL app.current_company_id = '501';
-- SET LOCAL app.current_dept_path = '1.5.20'; -- 用户所在部门的路径
-- SET LOCAL app.is_tenant_admin = 'false';
-- SET LOCAL app.data_scope = '3'; -- 用户最大的数据范围权限
第二步:编写通用的 RLS 策略函数
我们创建一个通用的函数来处理所有需要隔离的表(如订单表、客户表)。
sql
CREATE OR REPLACE FUNCTION check_data_permission()
RETURNS boolean AS $$
DECLARE
v_tid bigint;
v_is_tenant_admin boolean;
v_data_scope int;
v_user_company_id bigint;
v_user_dept_path ltree;
v_table_company_id bigint;
v_table_dept_id bigint;
BEGIN
-- 1. 获取当前会话变量
v_tid := NULLIF(current_setting('app.current_tenant_id', true), '')::bigint;
-- 超级管理员直接放行 (如果是超级管理员,通常不设置 tenant_id,或者在应用层直接拦截)
IF v_tid IS NULL THEN
RETURN true;
END IF;
-- 2. 获取表记录的租户ID (假设表都有 tenant_id 字段)
-- 这是一个动态写法,实际 PG 需要针对特定表写 Policy,这里演示逻辑
-- 在具体 Policy 中为: NEW.tenant_id 或 OLD.tenant_id
-- 这里假设表名为 target_table,字段为 tenant_id
-- v_table_tid := NEW.tenant_id;
-- 3. 租户级隔离 (基础红线)
-- 在具体 Policy 中写: WHERE tenant_id = v_tid
-- 下面是具体的行级过滤逻辑 (DataScope)
-- 获取用户权限上下文
v_is_tenant_admin := NULLIF(current_setting('app.is_tenant_admin', true), '')::boolean;
v_data_scope := NULLIF(current_setting('app.data_scope', true), '')::int;
v_user_company_id := NULLIF(current_setting('app.current_company_id', true), '')::bigint;
v_user_dept_path := NULLIF(current_setting('app.current_dept_path', true), '')::ltree;
-- 4. 租户管理员 (看租户下所有)
IF v_is_tenant_admin THEN
RETURN true;
END IF;
-- 5. 根据 DataScope 判断
IF v_data_scope = 1 THEN
-- 全部数据 (在当前租户下)
RETURN true;
ELSIF v_data_scope = 2 THEN
-- 本公司及下级
-- 假设业务表有 company_id 字段
-- RETURN (NEW.company_id = v_user_company_id);
-- 如果需要严格的公司层级,可以用 ltree 查 sys_company 表,但通常公司隔离 ID 相等即可
RETURN true; -- 简化示意
ELSIF v_data_scope = 3 THEN
-- 本部门及下级 (利用 ltree 的 @> 操作符)
-- 假设业务表有 dept_id,我们需要根据 dept_id 去查 sys_department.tree_path
-- 这在 WHERE 条件里写比较复杂,建议在 Policy JOIN 查询
RETURN true; -- 简化示意,见下方具体 Policy 写法
ELSIF v_data_scope = 4 THEN
-- 仅本人
-- RETURN (NEW.creator_id = current_user_id);
RETURN true;
END IF;
RETURN false;
END;
$$ LANGUAGE plpgsql;
4. 实际应用示例:给业务表加 RLS
假设我们有一张业务表 bus_order。
sql
-- 1. 启用 RLS
ALTER TABLE bus_order ENABLE ROW LEVEL SECURITY;
-- 2. 创建策略:只能操作本租户的,且符合数据范围的
CREATE POLICY tenant_isolation_policy ON bus_order
FOR ALL
TO public
USING (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
AND
(
-- A. 租户管理员
NULLIF(current_setting('app.is_tenant_admin', true), '')::boolean = true
OR
-- B. 全部数据权限
NULLIF(current_setting('app.data_scope', true), '')::int = 1
OR
-- C. 本公司 (直接比对 ID)
(
NULLIF(current_setting('app.data_scope', true), '')::int = 2
AND company_id = NULLIF(current_setting('app.current_company_id', true), '')::bigint
)
OR
-- D. 本部门及下级 (利用子查询和 ltree)
(
NULLIF(current_setting('app.data_scope', true), '')::int = 3
AND EXISTS (
SELECT 1 FROM sys_department
WHERE id = bus_order.dept_id
AND tree_path <@ NULLIF(current_setting('app.current_dept_path', true), '')::ltree
)
)
OR
-- E. 仅本人
(
NULLIF(current_setting('app.data_scope', true), '')::int = 4
AND creator_id = NULLIF(current_setting('app.current_user_id', true), '')::bigint
)
)
);
5. 应用层代码(Go)变得极其简单
以前你需要写大量的 Where 拼接:
go
// 旧代码:非常痛苦
query := db.Where("tenant_id = ?", user.TenantID)
if user.IsTenantAdmin {
// pass
} else {
scope := user.GetMaxDataScope()
if scope == 2 {
query = query.Where("company_id = ?", user.CompanyID)
} else if scope == 3 {
// 递归查询子部门 ID...
childIDs := GetChildDeptIDs(user.DeptID)
query = query.Where("dept_id IN ?", childIDs)
}
}
var orders []Order
query.Find(&orders)
现在(基于 RLS):
go
// 新代码:中间件负责设置上下文,这里只管业务逻辑
// 中间件已经执行了: SET LOCAL app.current_tenant_id = '1001' ...
var orders []Order
// 这行 SQL 发送到 PG 后,PG 会自动应用上面的 POLICY,过滤掉没权限的数据
db.Find(&orders)
总结
通过 PG RLS + Ltree ,我们将复杂度从 N*M(N个业务接口 * M种权限判断)降到了 O(1)。
- 模型层 :只需加
TreePath字段。 - 中间件层 :负责计算用户的
DeptPath和DataScope,并SET LOCAL。 - 业务层:完全透明,像没有权限系统一样写代码。
- 数据库层:通过 Policy 统一守住安全底线。