已发布的《企业后台权限设计:数据权限和角色权限的区别有多大?》讲的是概念。这篇讲实现:ApplyDataPermission到底生成了什么条件,为什么只做查询过滤是不够的,以及导入 Excel 时那条最容易被忽略的越权路径是怎么被堵上的。
一、先分清两个问题
功能权限回答的是:你能不能打开这个页面、能不能点这个按钮。
数据权限回答的是:打开页面之后,你能看到、能修改哪些行。
这两件事在实现上完全不同:
- 功能权限的载体是
SysMenu(菜单 + 按钮),判定方法是AuthPath/AuthButton; - 数据权限的载体是
SysRole.DataPermission和实体上的OrgId,判定方法是生成 SQL 过滤条件。
一个很典型的漏洞场景:用户确实有"订单管理"的编辑按钮权限(功能权限没问题),但他把请求里的Id改成别人的订单,如果只校验按钮权限,这条别人的数据就被改了。
所以数据权限必须覆盖查询、新增、修改、删除、批量导入所有落库路径。
二、模型:一个接口 + 一个枚举
1. 实体侧:IDataPermission
publicinterfaceIDataPermission{/// <summary>获取或设置组织 ID</summary>longOrgId{get;set;}}就这么简单。实体实现了它,就表示"这张表的每一行归属于某个组织"。
实际生效还需要第二个条件:实体同时实现IEntityCreated(提供CreatedUserId,用于"仅本人数据"规则)。源码里的自动判定:
// 实体实现 IDataPermission + IEntityCreated 时自动启用数据权限_autoDataPermission=typeof(IDataPermission).IsAssignableFrom(typeof(TItem))&&typeof(IEntityCreated).IsAssignableFrom(typeof(TItem));SysUser就是这样一个例子:public partial class SysUser : EntityFull, IDataPermission,而EntityFull的继承链上带了IEntityCreated。
2. 角色侧:五种数据范围
publicenumDataPermissionType{[Display(Name="全部数据")]AllData,[Display(Name="本部门及以下数据")]CurrentDepartmentAndBelow,[Display(Name="本部门数据")]CurrentDepartmentOnly,[Display(Name="仅本人数据")]PersonalOnly,[Display(Name="自定义数据")]Custom}角色上还配了CustomDataPermission(逗号分隔的组织 ID 列表)。数据权限是按角色配的,一个用户有多个角色时取并集——这点在源码里体现得很直接。
三、查询路径:ApplyDataPermission
1. 完整实现
publicstaticISelect<T>ApplyDataPermission<T>(thisISelect<T>select,AdminContextadminContext,boolenable=false,stringuserIdField="CreatedUserId")whereT:class{if(!enable||!typeof(IDataPermission).IsAssignableFrom(typeof(T))||!typeof(IEntityCreated).IsAssignableFrom(typeof(T))){returnselect;}varuser=adminContext.User;varroles=adminContext.Roles;if(user==null||roles==null||roles.Count==0){returnselect.Where(a=>false);}// 管理员角色拥有全部数据权限if(roles.Any(r=>r.DataPermission==DataPermissionType.AllData))returnselect;varparam=Expression.Parameter(typeof(T),"a");varorgIdProp=Expression.Property(param,"OrgId");varuserIdProp=Expression.Property(param,userIdField);varuserIdConstant=Expression.Constant(user.Id);Expression?combinedCondition=null;foreach(varroleinroles){Expression?roleCondition=null;switch(role.DataPermission){caseDataPermissionType.PersonalOnly:roleCondition=Expression.Equal(userIdProp,userIdConstant);break;caseDataPermissionType.CurrentDepartmentOnly:caseDataPermissionType.CurrentDepartmentAndBelow:varorgIds=adminContext.GetOrgIds(role.DataPermission==DataPermissionType.CurrentDepartmentAndBelow);roleCondition=BuildOrgInExpression(orgIds,orgIdProp);break;caseDataPermissionType.Custom:// 未配置或配置了非法组织 ID 时按"无可见数据"处理(fail-closed)varcustomOrgIds=(role.CustomDataPermission??string.Empty).Split(',',StringSplitOptions.RemoveEmptyEntries|StringSplitOptions.TrimEntries).Select(s=>long.TryParse(s,outvarid)?id:(long?)null).Where(id=>id.HasValue).Select(id=>id!.Value).ToList();roleCondition=BuildOrgInExpression(customOrgIds,orgIdProp);break;}if(roleCondition!=null){combinedCondition=combinedCondition==null?roleCondition:Expression.OrElse(combinedCondition,roleCondition);}}if(combinedCondition!=null){varlambda=Expression.Lambda<Func<T,bool>>(combinedCondition,param);returnselect.Where(lambda);}returnselect;}2. 逐段拆解
(1)不满足条件就不过滤
enable = false、实体没实现IDataPermission、或者没实现IEntityCreated,都直接返回原查询。这是"显式开关 + 自动识别"的双保险:不实现接口的实体不会被误伤。
(2)拿不到用户或角色时 fail-closed
if(user==null||roles==null||roles.Count==0){returnselect.Where(a=>false);}注意这里是Where(a => false),不是"返回全部"。权限上下文不完整时宁可看不到数据,也不能放开。
(3)AllData 直接短路
只要用户有任意一个AllData角色,直接返回原查询,不生成任何条件。管理员角色(IsAdministrator)在角色页里通常也配成全部数据。
(4)多角色取并集
每个角色生成一个条件表达式,最后用Expression.OrElse串起来。举例:用户有"A 角色:本部门"和"B 角色:仅本人"两个角色,最终条件等价于:
(OrgIdIN(本部门及子部门)ORCreatedUserId=当前用户)这符合"角色叠加只增不减"的直觉。
(5)本部门:用树形 CTE 算范围
publicList<long>GetOrgIds(boolincludeChildren){if(User?.OrgId==null)return[];varorgIds=newList<long>();if(includeChildren){varchildOrgs=Orm.Select<SysOrg>().Where(a=>a.Id==User.OrgId).AsTreeCte().ToList();orgIds.AddRange(childOrgs.Select(a=>a.Id));}else{orgIds.Add(User.OrgId);}returnorgIds;}AsTreeCte()让数据库递归展开整棵子树。“本部门及以下"就是"自己 + 所有子孙部门”;"本部门"只取一个 ID。
(6)自定义:非法配置按无数据
CustomDataPermission里解析不出任何合法 ID 时,BuildOrgInExpression会返回:
privatestaticExpressionBuildOrgInExpression(List<long>orgIds,MemberExpressionorgIdProp){if(orgIds.Count==0)returnExpression.Constant(false);...}也就是WHERE 1 = 0。这条 fail-closed 设计很重要——配置漏了不该等于"看到全部数据"。
3. 生成的 SQL 长什么样
以"本部门及以下"为例,最终落到数据库上大致是:
SELECT*FROMblog_articleWHEREOrgId=100OROrgId=101OROrgId=105;"仅本人"则是:
SELECT*FROMblog_articleWHERECreatedUserId=9001;条件是表达式树直接拼进查询的,不是取出数据后在内存里过滤。这意味着分页Count、导出、排序都作用在同一个过滤后的集合上,不会出现"第一页看着正常,第二页混进别人的数据"。
四、新增路径:OrgId 由框架自动写
查询过滤做好了,新增时如果OrgId是手填的(或者默认 0),数据就会掉进"谁都看不到"或者"被误认为属于 0 号组织"的坑。
框架在AdminExtensions.cs里通过RepositoryOptions.AuditValue统一处理:
AuditValue=e=>{varuser=r.GetService<AdminContext>()?.User;if(user==null)return;// 插入操作时,设置创建用户信息if(e.AuditValueType==AuditValueType.Insert&&e.ObjectisIEntityCreatedobj1&&obj1!=null){obj1.CreatedUserId=user.Id;obj1.CreatedUserName=user.Username;obj1.CreatedTime=DateTime.Now;}// 更新操作时,设置修改用户信息if(e.AuditValueType==AuditValueType.Update&&e.ObjectisIEntityModifiedobj2&&obj2!=null){obj2.ModifiedUserId=user.Id;obj2.ModifiedUserName=user.Username;obj2.ModifiedTime=DateTime.Now;}// 用户部门if(e.AuditValueType==AuditValueType.Insert&&e.ObjectisIDataPermissionobj3&&obj3!=null){obj3.OrgId=user.OrgId;return;}}所以业务代码里不需要(也不应该)手填OrgId:插入时它会被当前用户的组织覆盖。
五、修改 / 删除 / 批量操作:FilterAuthorizedAsync
查询用 SQL 过滤没问题,但修改和删除是按主键直接落库的,没有"先查再改"这个过程。如果只靠前端传过来的实体,伪造Id就能越权。
所以框架提供了第二个入口:
/// <summary>/// 从候选记录中筛出"当前用户确实有权限操作"的记录(仅依据主键回查,条件与查询数据权限一致)。/// 用于 Excel 导入 / 批量更新 / 批量删除等按主键直接落库的路径,/// 防止只靠前端按钮权限而被伪造 Id 绕过。/// </summary>publicstaticasyncTask<List<T>>FilterAuthorizedAsync<T,TKey>(thisIAggregateRootRepository<T>repo,AdminContextadminContext,IEnumerable<T>candidates,boolenablePermission,stringuserIdField="CreatedUserId")whereT:class,IEntity<TKey>,new(){varlist=candidates?.ToList()??[];if(list.Count==0)return[];if(!repo.Select.IsDataPermissionEnabled(adminContext,enablePermission)){returnlist;}varids=list.Select(x=>x.Id).Distinct().ToList();varauthorized=awaitrepo.Select.Where(a=>ids.Contains(a.Id)).ApplyDataPermission(adminContext,true,userIdField).ToListAsync(a=>a.Id);varauthorizedSet=newHashSet<TKey>(authorized);returnlist.Where(x=>authorizedSet.Contains(x.Id)).ToList();}它的做法是按主键回查一次:把候选 Id 丢回数据库,用和查询完全相同的ApplyDataPermission条件筛一遍,只保留查得到的主键。查不到的说明越权。
用"回查"而不是"在内存里比对 OrgId"是有意为之:
- 规则只有一份,查询和写操作不会不一致;
- 本部门及子部门的展开是数据库算的,内存里没有完整组织树;
- 自定义数据权限的解析逻辑也复用同一段代码。
AdminTable在更新、删除、软删除路径上都调用了它:
// 更新if(changedType==ItemChangedType.Update){varauthorized=awaitFilterAuthorizedAsync([item],CommonLocalizer["没有权限修改该数据"]);if(authorized.Count==0)returnfalse;}// 删除items=awaitFilterAuthorizedAsync(items,CommonLocalizer["没有权限操作部分数据,已取消删除"]);if(items.Count==0)returnfalse;六、Excel 导入:最容易被忽略的越权入口
导入的默认实现是InsertOrUpdate:Id > 0的行按主键更新。也就是说,Excel 里写什么主键,就可能更新哪一行。
如果这里只做按钮权限校验,"能不能用导入"是过了,但"能导谁的数据"完全没管。所以AdminTable在导入路径上单独加了过滤:
privateasyncTask<List<TItem>>FilterAuthorizedImportRowsAsync(List<TItem>rows){if(rows.Count==0||!(UseDataPermission||_autoDataPermission)){returnrows;}varauthorized=awaitFilterAuthorizedAsync(rows);if(authorized.Count<rows.Count){varrejected=rows.Count-authorized.Count;awaitToastService.Warning(CommonLocalizer["导入结果"],string.Format(CommonLocalizer["已忽略 {0} 条没有权限操作的数据。"],rejected));}returnauthorized;}调用点在导入主流程里:
// 数据权限:Excel 导入/更新同样不能操作当前用户无权限的数据。// Id > 0 的行会走数据库按主键更新,因此必须逐行确认该记录在当前用户的数据权限范围内,// 不能只依赖前端按钮权限(否则伪造 Id 即可越权更新他人数据)rows=awaitFilterAuthorizedImportRowsAsync(rows);if(rows.Count==0)return;affectedRows=await_repo.Orm.InsertOrUpdate<TItem>().SetSource(rows).UpdateColumns(updateColumns).ExecuteAffrowsAsync();行为是"剔除 + 提示",而不是整批失败:有权限的行正常导入,越权的行被丢弃,用户能看到"已忽略 N 条没有权限操作的数据"。
如果你用OnImportAsync完全接管导入,记得自己补这一步——默认过滤不会执行。
七、测试是怎么锁定这些行为的
EasyAdminBlazor.Tests/Security/DataPermissionTests.cs用一套真实 SQLite 数据库覆盖了四条路径,每条都是"必须有权限的留下、越权的剔除":
| 测试 | 覆盖路径 | 断言 |
|---|---|---|
Query_OnlyReturnsAuthorizedRows | 查询 | 结果只包含本组织OrgId = 100 |
Query_CannotReadOtherUsersData | 查询 | 结果中不出现OrgId = 200 |
Update_ForgedIdOutsideScope_IsRejected | 更新 | 伪造 Id 被拦截,数据库状态未变 |
Update_WithinScope_IsAllowed | 更新 | 权限范围内可正常更新 |
ExcelImport_OtherOrgRow_IsDropped | 导入 | 混入的他人行被剔除,只保留自己的 |
Delete_OtherOrgRows_AreFilteredOut | 删除 | 越权记录未被删除 |
Admin_RoleWithAllData_SeesEverything | 管理员 | AllData角色不受限 |
DisabledDataPermission_DoesNotFilter | 关闭开关 | 保持既有行为,不做过滤 |
这些用例的价值不只是"测试过了",而是把设计意图写成了可执行的约束:四条路径都不能只靠 UI 权限。
八、边界与坑
userIdField默认是"CreatedUserId"。"仅本人"规则按这个字段判定。如果你的业务里"归属人"不是创建人,要在调用时传入正确的字段名。- 实体必须同时实现
IDataPermission和IEntityCreated,否则自动启用不生效;显式传UseDataPermission="true"也不会过滤(ApplyDataPermission里有接口判断)。 AdminTable的自动启用只发生在实体实现两个接口时,这时不需要页面写UseDataPermission。AdminSelectTable/AdminMultiSelect也支持数据权限,用法一致(UseDataPermission+ 同样的接口约定)。- 自定义
OnBeforeQuery里做的 Join / Include 要留意:数据权限条件作用在TItem上,Join 出来的关联表不会自动带过滤条件。 - 文件等非标准资源要单独判定。
SysFileController就没有复用IDataPermission,而是明确注释了"文件实体本身不实现IDataPermission,因此采用与数据权限一致的判定",并且上传人缺失时拒绝访问。 - 它不是通用行级权限引擎。"按金额区间、按状态、按自定义表达式"这类复杂规则不在当前模型里,需要自己在
OnBeforeQuery/ 写路径上补充。
九、小结
把数据权限的实现串起来,就是四个位置、一个原则:
| 路径 | 实现 |
|---|---|
| 查询 | ApplyDataPermission生成 SQL 过滤条件 |
| 新增 | AuditValue自动写入当前用户的OrgId |
| 修改 / 删除 / 批量 | FilterAuthorizedAsync按主键回查权限范围 |
| Excel 导入 | FilterAuthorizedImportRowsAsync逐行过滤 + 提示 |
一个原则:权限上下文不完整时 fail-closed。拿不到用户、拿不到角色、自定义配置解析为空,一律"看不到任何数据",而不是"放开全部"。
功能权限决定你能进哪扇门,数据权限决定你在门里能碰哪些东西。两者都做,才叫权限控制。
如果你正在用 .NET 10 + Blazor 做企业后台,数据隔离通常是绕不开的需求。EasyAdminBlazor 的数据权限模型可以直接用,需要扩展时也能顺着FreeSqlExtensions改。
- 文档:https://easyadmin.wang-zhan.com.cn/doc
- 源码:https://gitee.com/gudufy/EasyAdminBlazor