在应用程序中,尤其是在统计的时候, 需要使用数据权限来筛选数据行。 简单的说,张三看张三部门的数据, 李四看李四部门的数据;或者员工只能看自己的数据, 经理可以看部门的数据。这个在微软的文档中叫Row Level Security,字面翻译叫行级数据安全,简称RLS。

要实现RLS, 简单的思路就是加Where条件语句来做数据筛选。但是必须是先Where, 也就是在其他Where条件和OrderBy、Fetch Rows 之前执行, 否则会对 排序、分页查询造成影响。这是一个难点。
另一个难点是如何对现有的业务代码侵入性降到最低——不影响现有查询逻辑的写法,甚至当需要的时候,可以关闭RLS。为了校验数据, 必须保持RLS开关的灵活性,尤其是在开发阶段。

下面介绍我在项目中使用过的两种实现方式。

数据权限筛选(RLS)的实现(一) -- Security Policy方式实现
这个主要参考微软的官文介绍实现, 分三个步骤, a. 定义Predicate函数, 根据user参数来筛选数据, b. 定义Security Policy, 使用前面指定的Predicate函数, c.在指定表上应用Security Policy。
其中的user, 一种是通过当前连接数据库的登录用户来获取,一种是通过exec sp_set_session_context @key=N'userId', @value=@userId 来传入用户。后者更适合我们在应用查询中使用统一的连接字符串。由于我们数据访问层是通过EF来实现的, 所以我们统一在自定义的DbContext类型中做了改造:

 1 public abstract class RlsDbContext : DbContext
2 {
3
4 protected readonly IUserProvider userProvider;
5 protected RlsDbContext(
6 string connectionString,
7 IUserProvider userProvider)
8 : base(options)
9 {
10 this.connectionString = connectionString;
11 this.userProvider = userProvider;
12 }
13
14 protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
15 {
16 connection = new SqlConnection(connectionString);
17 if (enableRLS)
18 {
19 connection.StateChange += Connection_StateChange;
20 }
21
22 if (!enableMemoryDb)
23 {
24 optionsBuilder.UseSqlServer(connection);
25 }
26
27 base.OnConfiguring(optionsBuilder);
28 }
29
30 private void Connection_StateChange(object sender, System.Data.StateChangeEventArgs e)
31 {
32 if (e.CurrentState == ConnectionState.Open)
33 {
34 string userId = userProvider.CurrentUserId;
35 //此处判断条件用于流程Hook接口未配置认证而获取不到用户的情况
36 if (!string.IsNullOrEmpty(userId))
37 {
38 SqlCommand cmd = connection.CreateCommand();
39 cmd.CommandText = @"exec sp_set_session_context @key=N'userId', @value=@userId";
40 cmd.Parameters.AddWithValue("@userId", userId);
41 cmd.ExecuteNonQuery();
42 }
43 }
44 else if (e.CurrentState == ConnectionState.Closed)
45 {
46 //暂时注释:在分页查询场景下存在RLS获取总数之前SQL连接关闭的情况
47 //connection.StateChange -= Connection_StateChange;
48 }
49 }
50
51 }

这样, 我们就能确保在访问数据库的适合, 传入了当前用户信息

具体的示例, 可以参考《Row-Level Security
但是这个方式有个很大的问题, 就是性能不理想, 尤其是在判断条件中有or逻辑的时候。 比如这个场景:每个部门只能看自己的数据,如果是数据管理员,不论在哪个部门, 可以看所有部门的数据。加了or逻辑后, 大概1w行数据查询需要10s钟,这超出了应用能接收的范围。示例Predicate Function如下

 1 CREATE FUNCTION [dbo].[Predicate_MyFilter_RLS]
2 (
3 @orgId nvarchar(200)
4 )
5 RETURNS TABLE
6 WITH SCHEMABINDING
7 AS
8 RETURN
9 SELECT TOP 1 1 AS AccessPredicateResult
10 FROM dbo.[User] a
11 WHERE
12 a.UserId = SESSION_CONTEXT(N'UserId')
13 AND
14 (
15 a.OrgId = @orgId OR a.OrgId = '0000000000000000000000'
16 )
17 GO

关于性能问题的佐证,可以参考《Row-Level Security for Middle-Tier Apps – Using Disjunctions in the Predicate

由于性能问题的障碍, 所以我们放弃了这种实现方式。但是这种方式比较优雅的满足了上述的两个条件,即实现了底层数据先筛选的逻辑,也对业务查询方法无侵入。在简单的场景中,应该是一款适合的方案。

数据权限筛选(RLS)的实现(二) -- 后台RlsStrategy方式实现
另一种做法, 是我们自行研究的RlsStrategy的实现方式。首先我们了解下接口IRlsStragety

 1 public interface IRlsStragety<TEntity, TUserConstraintEntity>
2 {
3 Expression<Func<TUserConstraintEntity, bool>> UserPredicate
4 {
5 get;
6 }
7
8 Expression<Func<TEntity, object>> OuterKeySelector
9 {
10 get;
11 }
12
13 Expression<Func<TUserConstraintEntity, object>> InnerKeySelector
14 {
15 get;
16 }
17
18 bool Skip();
19 }

这里面提供了三个表达式和一个bool 方法判断是否要略过RLS筛选。
下面是一个基本的实现:

 1 public class GenericUserOrgRlsStragety<TEntity, TOrgUser> : IRlsStragety<TEntity, TOrgUser>
2 where TEntity : class, IUserId
3 where TOrgUser : class, IOrgUser
4 {
5 private readonly IOrgProvider userOrgProvider;
6 public GenericUserOrgRlsStragety(IOrgProvider userOrgProvider)
7 {
8 this.userOrgProvider = userOrgProvider;
9 }
10
11 public virtual Expression<Func<TOrgUser, bool>> UserPredicate
12 => user => user.OrgId == userOrgProvider.CurrentUserOrgId;
13
14 public virtual Expression<Func<TEntity, object>> OuterKeySelector
15 => entry => entry.UserId;
16
17 public virtual Expression<Func<TOrgUser, object>> InnerKeySelector
18 => user => user.UserId;
19
20 public virtual bool Skip()
21 {
22 return false;
23 }
24 }

下面我来解释下这个逻辑。 假设应用中有这样两张表
T_BizData(Id, BizAmount, Org) 和T_OrgUser(Org, User), 前者是业务表, 记录了业务数据和所属业务组织的机构,后者是机构人员表,记录了人员和机构之间的关系。 根据这两个表,我们可以实现OrgA的用户可以查看OrgA的数据, OrgB的用户可以查看OrgB的数据

如果不考虑RLS, 则查询语句是

Select * from T_BizData

如果考虑RLS, 则查询语句是

Select a.* from T_BizData a
inner join T_OrgUser b on a.Org=b.org
where b.User=@user

两者比较,我们发现多了一个限制表和三处灵活点:
1 限制表就是 inner join T_OrgUser b,
2 灵活点 a) 取左表属性; b)取右表属性; c)取右表条件判断

这三个灵活点就是我们接口定义的三个表达式, 限制表是作为泛型类型传入进来的。

理解了这一点, 我们就可以看看下面这个代码

 1         public static IQueryable<TEntity> FilterByUser<TDbContext, TEntity, TUserConstraintEntity>(
2 this IQueryable<TEntity> queryable,
3 TDbContext dbContext,
4 IRlsStragety<TEntity, TUserConstraintEntity> rlsStragety
5 )
6 where TDbContext : DbContext
7 where TEntity : class
8 where TUserConstraintEntity : class, IUserId
9 {
10 if (dbContext is null)
11 {
12 throw new System.ArgumentNullException(nameof(dbContext));
13 }
14
15 if (rlsStragety == null
16 || rlsStragety.UserPredicate == null
17 || rlsStragety.OuterKeySelector == null
18 || rlsStragety.InnerKeySelector == null
19 || rlsStragety.Skip()
20 )
21 {
22 return queryable;
23 }
24
25
26 IQueryable<TEntity> result = queryable.Join(
27 dbContext.Set<TUserConstraintEntity>()
28 .Where(rlsStragety.UserPredicate)
29 , rlsStragety.OuterKeySelector
30 , rlsStragety.InnerKeySelector
31 , (p, q) => p
32 );
33 return result;
34 }

我们都知道queryable 是EF实现查询的对象,它描述了查询的过程,所以我们在原queryable对象的基础上扩充了join逻辑, 从而实现了类似sql 语句的两表inner join查询。 该过程是在分页之前加入的,这样才能保证查询的结果。

 1         public virtual async Task<IPaged<TEntity>> GetPagedListAsync<TEntity>(object filter, CancellationToken cancellationToken = default) where TEntity : class
2 {
3 if (filter == null)
4 {
5 filter = new object();
6 }
7 IPaged<TEntity> result = new Paged<TEntity>();
8
9 IQueryable<TEntity> queryable = GetPagedQueryable<TEntity>(filter);
10 result.Rows = await queryable.ToListAsync(cancellationToken).ConfigureAwait(false);
11
12 IQueryable<TEntity> queryableForCount = GetCountQueryable<TEntity>(filter);
13 result.Total = await queryableForCount.CountAsync(cancellationToken).ConfigureAwait(false);
14
15 return result;
16 }

以上准备工作做好了, 在查询的时候,就可以这样写了:

stragety =
serviceProvider.GetService<MyRlsStragety>(); var pageList = await rlsDataInquirer.GetPagedListAsync(filter, stragety);

最后, 补充下skip()方法的逻辑。

        public override bool Skip()
{
string orgId = userOrgProvider.CurrentUserOrgId; // 如果是信息管理部则跳过关联判断
return orgId.Equals(InfoSupervisorDepartmentOrgId, StringComparison.CurrentCultureIgnoreCase);
}

我们看到,FilterByUser方法的第19行, 如果skip()返回为true, 则会跳过RLS的逻辑。这个主要是为了特殊处理高级管理权限设计的。

总结:

使用Security Policy 除了可以过滤用户权限数据外, 还可以用于更新和删除数据时的权限检查; 而使用RlsStrategy则只能基于现有的框架来实现查询数据行时的筛选,但是性能上要好很多,而且也比较灵活。同时,因为底层是转换成了SQL语句,所以对字段加索引应该可以进一步提高查询的性能。

数据权限筛选(RLS)的两种实现介绍的更多相关文章

  1. 用easyui从servlet传递json数据到前端页面的两种方法

    用easyui从servlet传递json数据到前端页面的两种方法 两种方法获取的数据在servlet层传递的方法相同,下面为Servlet中代码,以查询表中所有信息为例. //重写doGet方法 p ...

  2. [转载]C#读写txt文件的两种方法介绍

    C#读写txt文件的两种方法介绍 by 大龙哥 1.添加命名空间 System.IO; System.Text; 2.文件的读取 (1).使用FileStream类进行文件的读取,并将它转换成char ...

  3. C#读写txt文件的两种方法介绍

    C#读写txt文件的两种方法介绍 1.添加命名空间 System.IO; System.Text; 2.文件的读取 (1).使用FileStream类进行文件的读取,并将它转换成char数组,然后输出 ...

  4. C#读写txt文件的两种方法介绍[转]

    C#读写txt文件的两种方法介绍 1.添加命名空间 System.IO; System.Text; 2.文件的读取 (1).使用FileStream类进行文件的读取,并将它转换成char数组,然后输出 ...

  5. C#读写txt文件的两种方法介绍 v

    C#读写txt文件的两种方法介绍 1.添加命名空间 System.IO; System.Text; 2.文件的读取 (1).使用FileStream类进行文件的读取,并将它转换成char数组,然后输出 ...

  6. MySQL修改数据表存储引擎的3种方法介绍

    这篇文章主要介绍了MySQL修改数据表存储引擎的3种方法介绍,分别是直接修改.导出导入.创建插入3种方法, 可以参考下   MySQL作为最常用的数据库,经常遇到各种各样的问题.今天要说的就是表存储引 ...

  7. oracle数据的导入导出(两种方法三种方式)

    大概了解数据库中数据的导入导出.在oracle中,导入导出数据的方法有两种,一种是使用cmd命令行的形式导入导出数据,另一种是使用PL/SQL工具导入导出数据. 1,使用cmd命令行导入导出数据 1. ...

  8. jsp中使用动态数据进行mySQL数据库的两种操作方法

    使用动态数据进行数据库内容的增删改查操作有两种方法: 在此定义数据库连接为conn 假设有表单进行数据输入并提交到处理页面一种是使用预编译格式: 其格式如下: String name = reques ...

  9. 根目录/缺少执行权限x产生的两种错误

    Linux根目录缺少x权限,产生的两个错误: 以root用户执行systemctl命令报权限相关问题 [root@hps2 ~]# systemctl stop hps-manager * (pktt ...

随机推荐

  1. 前端必读:Vue响应式系统大PK(下)

    转载请注明出处:葡萄城官网,葡萄城为开发者提供专业的开发工具.解决方案和服务,赋能开发者. 原文参考:https://www.sitepoint.com/vue-3-reactivity-system ...

  2. make clean 清除之前编译的可执行文件及配置文件。 make distclean 清除所有生成的文件。

    https://blog.csdn.net/bb807777/article/details/108302105 make clean 清除之前编译的可执行文件及配置文件.make distclean ...

  3. 【Python成长之路】装逼的一行代码:快速共享文件

    [Python成长之路]装逼的一行代码:快速共享文件 2019-10-26 15:30:05 华为云 阅读数 335 文章标签: Python编程编程语言程序员Python开发 更多 分类专栏: 技术 ...

  4. MySQL给某个用户给某个库表设置权限

    -- 用root(最高权限的用户)进行以下操作-- 创建数据库:emc_power CREATE DATABASE emc_power DEFAULT CHARACTER SET utf8 COLLA ...

  5. Linux软件安装管理之——dpkg与apt-*详解

    Linux软件安装管理之--dpkg与apt-*详解 [Linux软件安装管理系列]- - 传送门: - -<Linux软件安装管理之--源码安装详解> - -<Linux软件安装管 ...

  6. python3 使用random函数批量产生注册邮箱

    '''你是一个高级测试工程师,现在要做性能测试,需要你写一个函数,批量生成一些注册使用的账号. 1.产生的账号是以@163.com结尾,长度由用户输,产生多少条也由用户输入,2.用户名不能重复,用户名 ...

  7. (xxx) is not defined at HTMLInputElement.onblur(Day_27)

    错误: 这个报错我当时是卡了很久,方法是肯定没有问题的,但js所有的事件都失效了. 解决方案: 1.检查js命名是否有误,若外部引用js文件,尽量使用全小写命名,遵守js命名规范. 2.若还不行,请将 ...

  8. Kubernetes-3.3:ETCD集群搭建及使用(https认证+数据备份恢复)

    etcd集群搭建 环境介绍 基于CentOS Linux release 7.9.2009 (Core) ip hostname role 172.17.0.4 cd782d0a790b etcd1 ...

  9. MVC、MVP和MVVM的区别

    前言 在web1.0时代时,那个时候程序猿还没有前后端之分,更程序员开发的时候,都是要前后端一起写的,前后端的代码都是杂揉在一起,如图下 这种开发模式的话,开发的时候因为不需要和其他人员沟通协作,前后 ...

  10. 项目中添加lib依赖

    Project Structure-->Artifacts