已发布的《企业后台权限设计:数据权限和角色权限的区别有多大?》讲的是概念。这篇讲实现:ApplyDataPermission 到底生成了什么条件,为什么只做查询过滤是不够的,以及导入 Excel 时那条最容易被忽略的越权路径是怎么被堵上的。
一、先分清两个问题
功能权限回答的是:你能不能打开这个页面、能不能点这个按钮。
数据权限回答的是:打开页面之后,你能看到、能修改哪些行。
这两件事在实现上完全不同:
- 功能权限的载体是
SysMenu(菜单 + 按钮),判定方法是AuthPath/AuthButton; - 数据权限的载体是
SysRole.DataPermission和实体上的OrgId,判定方法是生成 SQL 过滤条件。
一个很典型的漏洞场景:用户确实有"订单管理"的编辑按钮权限(功能权限没问题),但他把请求里的 Id 改成别人的订单,如果只校验按钮权限,这条别人的数据就被改了。
所以数据权限必须覆盖查询、新增、修改、删除、批量导入所有落库路径。
二、模型:一个接口 + 一个枚举
1. 实体侧:IDataPermission
public interface IDataPermission
{
/// <summary>获取或设置组织 ID</summary>
long OrgId { 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. 角色侧:五种数据范围
public enum DataPermissionType
{
[Display(Name = "全部数据")] AllData,
[Display(Name = "本部门及以下数据")] CurrentDepartmentAndBelow,
[Display(Name = "本部门数据")] CurrentDepartmentOnly,
[Display(Name = "仅本人数据")] PersonalOnly,
[Display(Name = "自定义数据")] Custom
}
角色上还配了 CustomDataPermission(逗号分隔的组织 ID 列表)。数据权限是按角色配的,一个用户有多个角色时取并集——这点在源码里体现得很直接。
三、查询路径:ApplyDataPermission
1. 完整实现
public static ISelect<T> ApplyDataPermission<T>(this ISelect<T> select, AdminContext adminContext,
bool enable = false, string userIdField = "CreatedUserId") where T : class
{
if (!enable ||
!typeof(IDataPermission).IsAssignableFrom(typeof(T)) ||
!typeof(IEntityCreated).IsAssignableFrom(typeof(T)))
{
return select;
}
var user = adminContext.User;
var roles = adminContext.Roles;
if (user == null || roles == null || roles.Count == 0)
{
return select.Where(a => false);
}
// 管理员角色拥有全部数据权限
if (roles.Any(r => r.DataPermission == DataPermissionType.AllData))
return select;
var param = Expression.Parameter(typeof(T), "a");
var orgIdProp = Expression.Property(param, "OrgId");
var userIdProp = Expression.Property(param, userIdField);
var userIdConstant = Expression.Constant(user.Id);
Expression? combinedCondition = null;
foreach (var role in roles)
{
Expression? roleCondition = null;
switch (role.DataPermission)
{
case DataPermissionType.PersonalOnly:
roleCondition = Expression.Equal(userIdProp, userIdConstant);
break;
case DataPermissionType.CurrentDepartmentOnly:
case DataPermissionType.CurrentDepartmentAndBelow:
var orgIds = adminContext.GetOrgIds(role.DataPermission == DataPermissionType.CurrentDepartmentAndBelow);
roleCondition = BuildOrgInExpression(orgIds, orgIdProp);
break;
case DataPermissionType.Custom:
// 未配置或配置了非法组织 ID 时按"无可见数据"处理(fail-closed)
var customOrgIds = (role.CustomDataPermission ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var id) ? 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)
{
var lambda = Expression.Lambda<Func<T, bool>>(combinedCondition, param);
return select.Where(lambda);
}
return select;
}
2. 逐段拆解
(1)不满足条件就不过滤
enable = false、实体没实现 IDataPermission、或者没实现 IEntityCreated,都直接返回原查询。这是"显式开关 + 自动识别"的双保险:不实现接口的实体不会被误伤。
(2)拿不到用户或角色时 fail-closed
if (user == null || roles == null || roles.Count == 0)
{
return select.Where(a => false);
}
注意这里是 Where(a => false),不是"返回全部"。权限上下文不完整时宁可看不到数据,也不能放开。
(3)AllData 直接短路
只要用户有任意一个 AllData 角色,直接返回原查询,不生成任何条件。管理员角色(IsAdministrator)在角色页里通常也配成全部数据。
(4)多角色取并集
每个角色生成一个条件表达式,最后用 Expression.OrElse 串起来。举例:用户有"A 角色:本部门"和"B 角色:仅本人"两个角色,最终条件等价于:
(OrgId IN (本部门及子部门) OR CreatedUserId = 当前用户)
这符合"角色叠加只增不减"的直觉。
(5)本部门:用树形 CTE 算范围
public List<long> GetOrgIds(bool includeChildren)
{
if (User?.OrgId == null) return [];
var orgIds = new List<long>();
if (includeChildren)
{
var childOrgs = Orm.Select<SysOrg>().Where(a => a.Id == User.OrgId).AsTreeCte().ToList();
orgIds.AddRange(childOrgs.Select(a => a.Id));
}
else
{
orgIds.Add(User.OrgId);
}
return orgIds;
}
AsTreeCte() 让数据库递归展开整棵子树。"本部门及以下"就是"自己 + 所有子孙部门";"本部门"只取一个 ID。
(6)自定义:非法配置按无数据
CustomDataPermission 里解析不出任何合法 ID 时,BuildOrgInExpression 会返回:
private static Expression BuildOrgInExpression(List<long> orgIds, MemberExpression orgIdProp)
{
if (orgIds.Count == 0)
return Expression.Constant(false);
...
}
也就是 WHERE 1 = 0。这条 fail-closed 设计很重要——配置漏了不该等于"看到全部数据"。
3. 生成的 SQL 长什么样
以"本部门及以下"为例,最终落到数据库上大致是:
SELECT * FROM blog_article
WHERE OrgId = 100 OR OrgId = 101 OR OrgId = 105;
"仅本人"则是:
SELECT * FROM blog_article WHERE CreatedUserId = 9001;
条件是表达式树直接拼进查询的,不是取出数据后在内存里过滤。这意味着分页 Count、导出、排序都作用在同一个过滤后的集合上,不会出现"第一页看着正常,第二页混进别人的数据"。
四、新增路径:OrgId 由框架自动写
查询过滤做好了,新增时如果 OrgId 是手填的(或者默认 0),数据就会掉进"谁都看不到"或者"被误认为属于 0 号组织"的坑。
框架在 AdminExtensions.cs 里通过 RepositoryOptions.AuditValue 统一处理:
AuditValue = e => {
var user = r.GetService<AdminContext>()?.User;
if (user == null) return;
// 插入操作时,设置创建用户信息
if (e.AuditValueType == AuditValueType.Insert && e.Object is IEntityCreated obj1 && obj1 != null)
{
obj1.CreatedUserId = user.Id;
obj1.CreatedUserName = user.Username;
obj1.CreatedTime = DateTime.Now;
}
// 更新操作时,设置修改用户信息
if (e.AuditValueType == AuditValueType.Update && e.Object is IEntityModified obj2 && obj2 != null)
{
obj2.ModifiedUserId = user.Id;
obj2.ModifiedUserName = user.Username;
obj2.ModifiedTime = DateTime.Now;
}
// 用户部门
if (e.AuditValueType == AuditValueType.Insert && e.Object is IDataPermission obj3 && obj3 != null)
{
obj3.OrgId = user.OrgId;
return;
}
}
所以业务代码里不需要(也不应该)手填 OrgId:插入时它会被当前用户的组织覆盖。
五、修改 / 删除 / 批量操作:FilterAuthorizedAsync
查询用 SQL 过滤没问题,但修改和删除是按主键直接落库的,没有"先查再改"这个过程。如果只靠前端传过来的实体,伪造 Id 就能越权。
所以框架提供了第二个入口:
/// <summary>
/// 从候选记录中筛出"当前用户确实有权限操作"的记录(仅依据主键回查,条件与查询数据权限一致)。
/// 用于 Excel 导入 / 批量更新 / 批量删除等按主键直接落库的路径,
/// 防止只靠前端按钮权限而被伪造 Id 绕过。
/// </summary>
public static async Task<List<T>> FilterAuthorizedAsync<T, TKey>(
this IAggregateRootRepository<T> repo,
AdminContext adminContext,
IEnumerable<T> candidates,
bool enablePermission,
string userIdField = "CreatedUserId")
where T : class, IEntity<TKey>, new()
{
var list = candidates?.ToList() ?? [];
if (list.Count == 0) return [];
if (!repo.Select.IsDataPermissionEnabled(adminContext, enablePermission))
{
return list;
}
var ids = list.Select(x => x.Id).Distinct().ToList();
var authorized = await repo.Select
.Where(a => ids.Contains(a.Id))
.ApplyDataPermission(adminContext, true, userIdField)
.ToListAsync(a => a.Id);
var authorizedSet = new HashSet<TKey>(authorized);
return list.Where(x => authorizedSet.Contains(x.Id)).ToList();
}
它的做法是按主键回查一次:把候选 Id 丢回数据库,用和查询完全相同的 ApplyDataPermission 条件筛一遍,只保留查得到的主键。查不到的说明越权。
用"回查"而不是"在内存里比对 OrgId"是有意为之:
- 规则只有一份,查询和写操作不会不一致;
- 本部门及子部门的展开是数据库算的,内存里没有完整组织树;
- 自定义数据权限的解析逻辑也复用同一段代码。
AdminTable 在更新、删除、软删除路径上都调用了它:
// 更新
if (changedType == ItemChangedType.Update)
{
var authorized = await FilterAuthorizedAsync([item], CommonLocalizer["没有权限修改该数据"]);
if (authorized.Count == 0) return false;
}
// 删除
items = await FilterAuthorizedAsync(items, CommonLocalizer["没有权限操作部分数据,已取消删除"]);
if (items.Count == 0) return false;
六、Excel 导入:最容易被忽略的越权入口
导入的默认实现是 InsertOrUpdate:Id > 0 的行按主键更新。也就是说,Excel 里写什么主键,就可能更新哪一行。
如果这里只做按钮权限校验,"能不能用导入"是过了,但"能导谁的数据"完全没管。所以 AdminTable 在导入路径上单独加了过滤:
private async Task<List<TItem>> FilterAuthorizedImportRowsAsync(List<TItem> rows)
{
if (rows.Count == 0 || !(UseDataPermission || _autoDataPermission))
{
return rows;
}
var authorized = await FilterAuthorizedAsync(rows);
if (authorized.Count < rows.Count)
{
var rejected = rows.Count - authorized.Count;
await ToastService.Warning(
CommonLocalizer["导入结果"],
string.Format(CommonLocalizer["已忽略 {0} 条没有权限操作的数据。"], rejected));
}
return authorized;
}
调用点在导入主流程里:
// 数据权限:Excel 导入/更新同样不能操作当前用户无权限的数据。
// Id > 0 的行会走数据库按主键更新,因此必须逐行确认该记录在当前用户的数据权限范围内,
// 不能只依赖前端按钮权限(否则伪造 Id 即可越权更新他人数据)
rows = await FilterAuthorizedImportRowsAsync(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 改。