20. 数据权限与多租户过滤
一句话定位:Admin.NET 把"软删除 / 租户隔离 / 机构数据范围 / 自定义业务过滤"四类条件做成 SqlSugar 全局查询过滤器(QueryFilter),在每次获取数据库连接时一次性注入,业务 SQL 不用写 WHERE TenantId=?。
一、注入时机与位置
SqlSugarSetup.AddSqlSugar() // Admin.NET.Core/SqlSugar/SqlSugarSetup.cs
└─ new SqlSugarScope(cfgs, db => { ... SetDbAop(dbProvider, ...) })
└─ SetDbAop(dbProvider, enableConsoleSql)
├─ Aop.OnError / OnLogExecuting / OnLogExecuted (日志)
├─ Aop.DataExecuting (审计:Id/时间/创建人或机构自动赋值)
├─ if 超管 → return ← 【超管绕过点】
├─ QueryFilter.AddTableFilter<IDeletedFilter>(u => u.IsDelete == false)
├─ QueryFilter.AddTableFilter<ITenantIdFilter>(u => u.TenantId == tenantId)
├─ SqlSugarFilter.SetOrgEntityFilter(dbProvider) (机构/数据范围)
└─ SqlSugarFilter.SetCustomEntityFilter(dbProvider) (IEntityFilter)关键点:过滤器在 SqlSugarScope 的回调里注册,而 SqlSugarScope 是单例(services.AddSingleton<ISqlSugarClient>(sqlSugar))。每个 Scope 取到的 SqlSugarScopeProvider 会执行该回调,因此用户上下文(App.User)变化时过滤器会随之重算。
二、三个(实际四个)全局过滤器
2.1 软删除 IDeletedFilter
// SqlSugarSetup.SetDbAop
dbProvider.QueryFilter.AddTableFilter<IDeletedFilter>(u => u.IsDelete == false);EntityBase : EntityBaseId, IDeletedFilter 自带 IsDelete、DeleteTime,所以所有继承 EntityBase 的实体自动获得软删除过滤。更新/删除时同样生效(IsAutoDeleteQueryFilter、IsAutoUpdateQueryFilter 已在 SetDbConfig 打开)。
2.2 租户 ITenantIdFilter
var tenantId = App.User?.FindFirst(ClaimConst.TenantId)?.Value;
if (!string.IsNullOrWhiteSpace(tenantId))
dbProvider.QueryFilter.AddTableFilter<ITenantIdFilter>(u => u.TenantId == long.Parse(tenantId));实现 ITenantIdFilter 的基类:EntityTenant、EntityTenantId、以及业务常用的 EntityTenantBaseData(租户 + 数据范围)。新增/插入时,Aop.DataExecuting 会自动给空的 TenantId 赋当前 Claim 值(默认为 SqlSugarConst.DefaultTenantId)。
需要跨租户查询(例如平台侧统计):
await _rep.AsQueryable().ClearFilter<ITenantIdFilter>().ToListAsync();2.3 机构/数据范围 EntityBaseData
SqlSugarFilter.SetOrgEntityFilter(db) 的执行顺序:
SetOrgEntityFilter(db)
├─ maxDataScope = SetDataScopeFilter(db) // 顺带处理“仅本人”档
│ └─ 读缓存 sys_role_maxDataScope:{userId} → 若 Self 则给所有 EntityBaseData 加 CreateUserId=当前用户 过滤
├─ 若 maxDataScope 为 0 或 Self → 直接 return(Self 已处理完)
├─ 若 All → return(不加任何机构过滤)
└─ 否则:取 GetUserOrgIdList() 的机构集合 → 为每个带 [SugarTable] 且继承 EntityBaseData 的实体
生成 (CreateOrgId IN ids) OR (UpdateOrgId IN ids) 表达式并 AddTableFilter两个 assert 等价 SQL:
-- Self(仅本人数据)
SELECT * FROM BizOrder WHERE CreateUserId = <当前UserId>
-- Dept / DeptChild / Define(本部门或自定义)
SELECT * FROM BizOrder WHERE CreateOrgId IN (1,2,3) OR UpdateOrgId IN (1,2,3)表达式由 GetConditionExpression<TAttribute> 动态构造(SqlSugarFilter 内已提供),它扫描实体上贴了 [OwnerOrg]/[OwnerUser] 的属性并做 Enumerable.Contains 的 OR 拼接:
// 源码片段
public static LambdaExpression GetConditionExpression<T>(this Type type, List<long> owners) where T : Attribute
{
var fieldNames = type.GetPropertyNames<T>();
var parameter = Expression.Parameter(type, "c");
Expression right = Expression.Constant(false);
var ownersCollection = Expression.Constant(owners);
foreach (var fieldName in fieldNames) { /* 处理 Nullable;Contains(member) OR right */ }
return Expression.Lambda(right, parameter);
}2.4 自定义 IEntityFilter
扫描所有实现 IEntityFilter 的类型,反射调用 AddEntityFilter():
// SqlSugarFilter.SetCustomEntityFilter 核心
var entityFilterTypes = App.EffectiveTypes.Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass
&& u.GetInterfaces().Any(i => i.HasImplementedRawGeneric(typeof(IEntityFilter))));三、DataScopeEnum 五档含义与配置路径
// Admin.NET.Core/Enum/DataScopeEnum.cs
public enum DataScopeEnum
{
[Description("全部数据"), Theme("danger")] All = 1,
[Description("本部门及以下数据"), Theme("warning")] DeptChild = 2,
[Description("本部门数据"), Theme("primary")] Dept = 3,
[Description("仅本人数据"), Theme("info")] Self = 4,
[Description("自定义数据"), Theme("warning")] Define = 5
}| 档位 | 值 | 实际注入的过滤 | 角色上怎么配 |
|---|---|---|---|
| 全部数据 | 1 | 不加机构过滤 | 系统管理 → 角色管理 → 数据范围 = 全部数据 |
| 本部门及以下 | 2 | CreateOrgId/UpdateOrgId IN (本部门 + 所有子部门) | 同上选择对应项 |
| 本部门 | 3 | IN (本部门) | 同上 |
| 仅本人 | 4 | CreateUserId = 当前用户 | 同上 |
| 自定义 | 5 | IN (SysRoleOrg 里勾的机构) | 角色管理 →数据范围选自定义 + 勾选机构 |
配置入口路径:
系统管理 → 角色管理 → 编辑角色 → 【数据范围】下拉框
系统管理 → 角色管理 → 编辑角色 → 【数据权限】勾选机构(仅自定义档位需要,写 SysRoleOrg)
系统管理 → 用户管理 → 编辑用户 → 【角色】【机构】【附属机构】(SysUserExtOrg)多角色时的合并策略在 SysOrgService.GetUserOrgIdList(List<SysRole> roleList, long userId, long userOrgId):
// 按最大范围策略设定(若同时拥有ALL和SELF权限,则结果ALL)
int strongerDataScopeType = (int)DataScopeEnum.Self;
foreach (var u in roleList)
{
if (u.DataScope == DataScopeEnum.Define)
{
customDataScopeRoleIdList.Add(u.Id);
strongerDataScopeType = (int)u.DataScope; // 自定义也要刷新最大范围
}
else if ((int)u.DataScope <= strongerDataScopeType)
{
strongerDataScopeType = (int)u.DataScope;
var orgIds = GetOrgIdListByDataScope(userOrgId, strongerDataScopeType).GetAwaiter().GetResult();
dataScopeOrgIdList = dataScopeOrgIdList.Union(orgIds).ToList();
}
}
_sysCacheService.Set($"{CacheConst.KeyRoleMaxDataScope}{userId}", strongerDataScopeType, TimeSpan.FromDays(7));
return (await _sysRoleOrgService.GetRoleOrgIdList(customDataScopeRoleIdList)).Union(dataScopeOrgIdList).ToList();最终用户可见机构集合 = 扩展机构(SysUserExtOrg) ∪ 角色机构 ∪ 本人创建的机构 ∪ 当前所属机构,缓存在 sys_user_org:{userId}。
四、[OwnerUser] / [OwnerOrg] 怎么用
这两个特性标记"这个字段是数据范围判定列"。基类已经打好了:
// Admin.NET.Core/Entity/EntityBase.cs
public abstract class EntityBase : EntityBaseId, IDeletedFilter
{
[OwnerUser]
public virtual long? CreateUserId { get; set; } // Self 档依据
...
}
public abstract class EntityBaseData : EntityBase, IOrgIdFilter
{
[OwnerOrg] public virtual long? CreateOrgId { get; set; } // 机构档依据
[OwnerOrg] public virtual long? UpdateOrgId { get; set; }
public virtual string? CreateOrgName { get; set; }
public virtual string? UpdateOrgName { get; set; }
}业务实体只需继承正确的基类即可,零配置获得数据权限:
[SugarTable(null, "业务订单表")]
public class BizOrder : EntityTenantBaseData // 或 EntityBaseData(单租户)
{
[SugarColumn(ColumnDescription = "订单号", Length = 64)]
public string OrderNo { get; set; }
[SugarColumn(ColumnDescription = "金额")]
public decimal Amount { get; set; }
}
EntityTenantBaseData同时实现ITenantIdFilter与IOrgIdFilter,是新建业务表最推荐的基类。若只想让部分字段参与范围判定,直接在该字段上贴[OwnerUser]/[OwnerOrg],过滤器会自动把它一起 OR 进表达式。
五、自定义过滤器 IEntityFilter
源码注掉的示例就在 SqlSugarFilter.cs 末尾,解开即用:
using SqlSugar;
/// <summary>
/// 约束:所有查询只看“未归档”的项目,且金额字段按兼顾 ETL 场景放行
/// </summary>
public class BizProjectFilter : IEntityFilter
{
public IEnumerable<TableFilterItem<object>> AddEntityFilter()
{
// 构造自定义条件的过滤器
Expression<Func<BizProject, bool>> exp = u => u.Status != ProjectStatusEnum.Archived;
var item = new TableFilterItem<object>(typeof(BizProject), exp);
return new[] { item };
}
}约束与注意:
- 类必须是
public、非抽象、有无参构造(Activator.CreateInstance反射实例化) - 结果会被缓存到内存 key:
db:{ConfigId}:custom:{userId} - 只作用注入时的那个库:
TenantAttribute与ConfigId不匹配的实体会被跳过 - 缓存不会自动失效,需要手动调
SqlSugarFilter.DeleteCustomCache(userId, configId)
六、缓存与失效
| 缓存 key | 类型 | 内容 |
|---|---|---|
sys_role_maxDataScope:{userId} | Redis/内存(SysCacheService) | 用户的最大数据范围值(int),7 天 |
sys_user_org:{userId} | Redis/内存 | 用户可见机构 Id 集合,7 天 |
sys_user_role:{userId} | Redis/内存 | 角色集合 |
sys_user_api:{userId} | Redis/内存 | 按钮权限白名单 + 接口黑名单 |
db:{ConfigId}:orgList:{userId} | 进程内存(Cache.Default) | 编译好的机构过滤 Lambda 表达式 |
db:{ConfigId}:dataScope:{userId} | 进程内存 | "仅本人"过滤表达式 |
db:{ConfigId}:custom:{userId} | 进程内存 | 自定义过滤器项 |
手动清理入口:
// Admin.NET.Core/SqlSugar/SqlSugarFilter.cs
public static void DeleteUserOrgCache(long userId, string dbConfigId)
{
var sysCacheService = App.GetRequiredService<SysCacheService>();
sysCacheService.Remove($"{CacheConst.KeyUserOrg}{userId}");
sysCacheService.Remove($"{CacheConst.KeyUserApi}{userId}");
sysCacheService.Remove($"{CacheConst.KeyUserRole}{userId}");
sysCacheService.Remove($"{CacheConst.KeyRoleMaxDataScope}{userId}");
_cache.Remove($"db:{dbConfigId}:orgList:{userId}"); // ⚠️ 只删了 orgList
}
public static void DeleteCustomCache(long userId, string dbConfigId)
{
_cache.Remove($"db:{dbConfigId}:custom:{userId}");
}框架内部的调用方(SysOrgService.DeleteAllUserOrgCache):在增删改机构时,遍历所有 sys_user_org:* 缓存,命中该机构的用户的缓存一律清掉。
业务侧典型调用——分配/回收角色后:
public async Task GrantRoleForUser(long userId, List<long> roleIdList)
{
await _sysUserRoleService.GrantRole(new RoleUserInput { UserId = userId, RoleIdList = roleIdList });
// 数据范围变了,必须清缓存,否则下一次查询还是旧范围
SqlSugarFilter.DeleteUserOrgCache(userId, SqlSugarConst.MainConfigId);
await App.GetRequiredService<SysRoleService>().ClearUserApiCache(0); // 或直接删 KeyUserApi
}七、超管绕过
// SqlSugarSetup.SetDbAop
if (App.User?.FindFirst(ClaimConst.AccountType)?.Value == ((int)AccountTypeEnum.SuperAdmin).ToString())
return;超级管理员(AccountTypeEnum.SuperAdmin,账号 superAdmin.NET)不注册任何查询过滤器,能看到全租户全量数据。同理:
JwtHandler.CheckAuthorizeAsync开头也对 SuperAdmin 直接放行SysOrgService.GetUserOrgIdList()开头if (_userManager.SuperAdmin) return [];
系统管理员(SysAdmin)不享受这个豁免,仍然按角色数据范围过滤。
八、一个完整示例:新建带数据权限的业务表
using Admin.NET.Core;
using SqlSugar;
namespace Admin.NET.Application.Entity;
/// <summary>
/// 客户拜访记录(受租户隔离 + 机构数据范围约束)
/// </summary>
[SugarTable(null, "客户拜访记录表")]
public class BizVisit : EntityTenantBaseData
{
[SugarColumn(ColumnDescription = "客户Id")]
public long CustomerId { get; set; }
[SugarColumn(ColumnDescription = "拜访内容", Length = 1024)]
public string? Content { get; set; }
[SugarColumn(ColumnDescription = "拜访时间")]
public DateTime VisitTime { get; set; }
}[ApiDescriptionSettings(Order = 900, Description = "客户拜访")]
public class BizVisitService : IDynamicApiController, ITransient
{
private readonly SqlSugarRepository<BizVisit> _visitRep;
public BizVisitService(SqlSugarRepository<BizVisit> visitRep) => _visitRep = visitRep;
/// <summary>
/// 分页查询 🔖 —— 不用写任何数据范围条件
/// </summary>
[DisplayName("拜访记录分页")]
public async Task<SqlSugarPagedList<BizVisit>> Page(PageVisitInput input)
{
return await _visitRep.AsQueryable()
.WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), u => u.Content!.Contains(input.Keyword!))
// 需要覆盖数据范围时(例如领导视图)显式清除:
// .ClearFilter<IOrgIdFilter>()
.OrderByDescending(u => u.VisitTime)
.ToPagedListAsync(input.Page, input.PageSize);
}
}Aop.DataExecuting 会自动补:Id(雪花)、CreateTime、CreateUserId、CreateUserName、CreateOrgId、CreateOrgName、TenantId;更新时补 UpdateTime、UpdateUserId、UpdateOrgId 等。所以插入时千万别手动塞 CreateOrgId,否则等于自己决定了数据归属。
九、步骤清单
- 新建业务实体,继承
EntityTenantBaseData(多租户 + 数据范围)或EntityBaseData(单租户) - 贴上
[SugarTable(null, "中文表名")] - 服务继承
IDynamicApiController,用SqlSugarRepository<T>查询,不写范围条件 - 到 系统管理 → 菜单管理 为新页面建菜单与按钮权限
- 到 系统管理 → 角色管理 给角色配置数据范围,必要时勾自定义机构
- 更新完成后,若是已登录用户,清
SqlSugarFilter.DeleteUserOrgCache(userId, configId)让用户重新拉权限 - 验证:分别用超管、本部门用户、仅本人用户登录,对比
/xxx/page返回的result.total
⚠️ 注意事项 / 常见坑
DeleteUserOrgCache不会删dataScope和custom缓存:源码只 Remove 了db:{ConfigId}:orgList:{userId}。如果用户从"仅本人"调到"本部门",旧的dataScope表达式仍在内存中,会导致重启前一直按仅本人过滤。处理方式:连滚 UI 一起布局时用DeleteUserOrgCache+DeleteCustomCache,并对dataScope场景提示用户退出重登,或自行扩展该方法同时 Removedb:{ConfigId}:dataScope:{userId}。- 进程内存缓存 ≠ 分布式缓存:
_cache是Cache.Default(进程内),多实例/集群部署时,A 节点清了缓存 B 节点不知道。要么接受重启生效,要么在ISqlSugarAop或业务里改成全局缓存。 - 后台线程 / 定时器没有
App.User:SetDbAop里所有App.User?.FindFirst(...)均为 null → 不加租户、不加软删除、不加数据范围。作业里要用Scoped.Create+ 手动 Set用户上下文,或明确用ClearFilter表达"我要全量"的意图。 CopyNew()之后过滤器依然存在:CopyNew()只复制连接,但新的 Provider 会重新走同一套SetDbAop,安全。- 写了原生 SQL / Ado 查询:全局过滤器只对 SqlSugar 查询表达式生效,
db.Ado.SqlQuery<T>("SELECT ...")、视图、存储过程都不会被过滤。自定义视图请自行在ISqlSugarView.GetQueryableSqlString里加条件。 - 导航查询(Includes)过滤问题:主表被过滤不等于子表被过滤,子集合若继承
EntityBaseData会各自应用自己的过滤器,通常是对的;但如果子表没继承基类,会泄漏跨租户数据。 - 超管绕过是所有过滤器的:包括软删除!超管能看到
IsDelete=true的数据。做回收站功能时要显式加.Where(u => u.IsDelete == false)。 - 角色数据范围修改后用户缓存:
GetUserOrgIdList的sys_user_org:{userId}有 7 天 TTL,仅靠DeleteAllUserOrgCache(机构变动触发)不够;授权逻辑改动务必手动清。 [OwnerUser]只认CreateUserId:如果你希望"销售负责人"字段也参与过滤,需要自己在该字段贴[OwnerUser],过滤器会自动把它纳入 Self 档判定。
小结
- 四类过滤器(软删除、租户、机构数据范围、自定义)统一在
SqlSugarSetup.SetDbAop里注册 - 五档
DataScopeEnum通过"取最大值"策略合并多角色,机构集合最终落在sys_user_org:{userId} [OwnerUser]/[OwnerOrg]声明过滤列,EntityTenantBaseData基类已经帮业务打好了标签- 自定义过滤实现
IEntityFilter,注意缓存手动清除 - 超管全绕过(含软删除);后台线程无用户上下文,不会加任何过滤
- 清缓存认准
SqlSugarFilter.DeleteUserOrgCache,并注意它没覆盖dataScope/custom两个 key