☰
Blazor 数据权限怎么实现?为什么查询、新增、修改、删除都要控制?
2026/10/8 8:18:03 网站建设 项目流程

已发布的《企业后台权限设计:数据权限和角色权限的区别有多大?》讲的是概念。这篇讲实现: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 权限。


八、边界与坑

  1. userIdField默认是"CreatedUserId"。"仅本人"规则按这个字段判定。如果你的业务里"归属人"不是创建人,要在调用时传入正确的字段名。
  2. 实体必须同时实现IDataPermission和IEntityCreated,否则自动启用不生效;显式传UseDataPermission="true"也不会过滤(ApplyDataPermission里有接口判断)。
  3. AdminTable的自动启用只发生在实体实现两个接口时,这时不需要页面写UseDataPermission。
  4. AdminSelectTable/AdminMultiSelect也支持数据权限,用法一致(UseDataPermission+ 同样的接口约定)。
  5. 自定义OnBeforeQuery里做的 Join / Include 要留意:数据权限条件作用在TItem上,Join 出来的关联表不会自动带过滤条件。
  6. 文件等非标准资源要单独判定。SysFileController就没有复用IDataPermission,而是明确注释了"文件实体本身不实现IDataPermission,因此采用与数据权限一致的判定",并且上传人缺失时拒绝访问。
  7. 它不是通用行级权限引擎。"按金额区间、按状态、按自定义表达式"这类复杂规则不在当前模型里,需要自己在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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询