数据权限筛选(RLS)的两种实现介绍
在应用程序中,尤其是在统计的时候, 需要使用数据权限来筛选数据行。 简单的说,张三看张三部门的数据, 李四看李四部门的数据;或者员工只能看自己的数据, 经理可以看部门的数据。这个在微软的文档中叫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语句,所以对字段加索引应该可以进一步提高查询的性能。
上一篇: Python爬虫之用Xpath获取关键标签实现自动评论盖楼抽奖(二)
下一篇: 然后好好骂你一顿