EasyAdminBlazor 数据权限源码解析:为什么查询、新增、修改、删除都必须控制?

原创 2026-09-27 855 次阅读

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


八、边界与坑

  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 改。