思考了几年的数据权限终于有解了

前面写了博客聊聊数据权限哪些事儿,实际上在写那篇文章的时候,思路就已经思考好了,然后就是代码实现了。相对于解决方案,代码简直就太容易了。

首先说说功能特性


  • 数据行权限--让不同的人看到不同的数据行的权限控制
  • 数据列权限--对使用者限制其访问其些列或某些列中的数据的权限控制

数据权限说起来比较简单,但是实际实现过程中,也是非常复杂的,比如,同样是select表,可能是对原样的表名及字段名进行访问,也可以是把表名和字段名取了别名,另外还会涉及到复杂的查询和子查询。

同样的数据列权限,还有的用场景是不同的权限的人员可以看到的权限范围是不一样的,比如:HR系统中,不同管理级别的HR可以看到的员工的工资范围是不同的,比如:只能看到<=被授权访问级别人员的工资信息,但是其它信息都是可以访问的。传统的做法下,这种情况只有写程序一种方案了,但是如果这个时候有许多种组合,这个代码写出来就没有办法看了。

在上篇文章中,本人有提到:一个好的数据权限方案要考虑以下方面:

  • 对程序员透明:整个方案对于程序员们来说,不论是前台还是后台的程序员们来说,越透明越好,他们最好不知道有这么回事情
  • 与不同的用户权限能较好集成:作为一个数据权限系统来说,肯定要和用户、角色、组织机构什么的数据打交道。如果这个方案只能和某一种用户权限系统集成,那么它充其量只能算是一个可用的解决方案,但是注定算不是上好的解决方案。
  • 性能损失最少:我们说,要增加新的功能或限制的过程中,肯定会对性能方面有一定影响的,怎么样能把性能损失降低,甚至能提升一定的性能是设计师们永恒的主题。
  • 对于分页能良好支持:上面有说到当采用了行权限时,有些行是不能显示给用户的,这个时候不能对分页有影响,比如本来是一页是10条的,现在忽然变成8条,甚至在极端情况下变成0条,这样的用户体验就非常差了。
  • 一次设定到处生效,哪怕是不同的ORMapping方案也都起作用:我们知道,现在采用唯一的一种ORMapping解决方案的场景已经越来越少了,不同的方案都有自己的优缺点,它可能只适应某种前置条件下的应用场景,实际应用的情况下根据场景不同使用不同的解决方案也是非常普遍的,这就要求解决方案有一定的普适定。
  • 跨数据库:一个项目,它的运行环境基本上是确定的。但是对于一个产品来说,根据用户的实际场景不同,可能对于数据库也有不同的要求,这个时候就需要对不同的数据库有支持,不能对于A数据库是支持的,对于B数据库的场景下,就出异常或者结果不正确了。

在此次实现过程中,我们对数据权限进行了进一步的引申,不仅针对select提供了数据权限,还针对insert、delete、update语句提供了权限控制,也就是说,通过tiny dac,甚至可以限制你插入或修改数据的值的范围,比如:你的权限插入时只能插入100以内的数据,当代试图插入大于100的数值的时候,就会出现SQL执行异常,提醒你执行范围超出了。

同样,还有多少种不用的用法,需要看你的脑洞可以开多大了。

当然,本次的实现方案,就需要比较优雅解决这些难题。我的任务在于提出问题和给出解决方案,编码 这种事情当然就直接让小弟们去完成就好了,偶的工作在于喝茶和“骂”人——直到实现的代码和偶的期望比较接近。

下面我们来看看几个示例:

数据规则配置

<data-access-controls>
    <data-access-control id="insertDataAccessControl">
        <connection driver="org.h2.Driver"
                    user-name="sa" password="123456" url="jdbc:h2:./dbfile/dac_simple;mode=MYSQL;DB_CLOSE_ON_EXIT=FALSE"
                    class="org.tinygroup.dac.config.DataSourceMode">
        </connection>
        <access-control-rule>
            <table-rules>
                <table-rule table-name="custom">
                    <insert-rule>
                        <filter-rules>
                            <filter-rule>
                                <!--过滤访客插入custom_group,age-->
                                <expression>
                                    <![CDATA[
                                        (#is_guest#!=null && #is_guest#==1)
                                    ]]>
                                </expression>
                                <filter-columns>
                                    <filter-column column-name="custom_group" ></filter-column>
                                    <filter-column column-name="age" ></filter-column>
                                </filter-columns>
                            </filter-rule>
                        </filter-rules>
                        <value-check-rules>
                            <value-check-rule column-name="name">
                                <expression>
                                    <![CDATA[
                                        #name#.contains("sds")
                                    ]]>
                                </expression>
                            </value-check-rule>
                        </value-check-rules>
                    </insert-rule>
                </table-rule>
                <!--非自增长测试字段过滤为全空的情况-->
                <table-rule table-name="score">
                    <insert-rule>
                        <filter-rules>
                            <filter-rule>
                                <!--过滤访客插入custom_group,age-->
                                <expression>
                                    <![CDATA[
                                        (#score#!=null && (#score#>100||#score#<0))
                                    ]]>
                                </expression>
                                <filter-columns>
                                    <filter-column column-name="score" ></filter-column>
                                </filter-columns>
                            </filter-rule>
                            <filter-rule>
                                <!--过滤敏感字段:gcd-->
                                <expression>
                                    <![CDATA[
                                        (#name#!=null && (#name#.contains("gcd")))
                                    ]]>
                                </expression>
                                <filter-columns>
                                    <filter-column column-name="name" ></filter-column>
                                </filter-columns>
                            </filter-rule>
                        </filter-rules>
                    </insert-rule>
                </table-rule>
            </table-rules>
        </access-control-rule>
    </data-access-control>
</data-access-controls>

大概解释一下上面的意思:

首先配置了数据权限要控制的真实物理数据源,目前支持两种形式的方式,一种是URL+用户名密码的方式,另外一种是JNDI数据源的形式。

接下来配置了数据访问的规则,可以分别配置SELECT,INSERT,UPDATE,DELETE的规则,以便对4种SQL语句进行精确控制。

<insert-rule>
                        <filter-rules>
                            <filter-rule>
                                <!--过滤访客插入custom_group,age-->
                                <expression>
                                    <![CDATA[
                                        (#is_guest#!=null && #is_guest#==1)
                                    ]]>
                                </expression>
                                <filter-columns>
                                    <filter-column column-name="custom_group" ></filter-column>
                                    <filter-column column-name="age" ></filter-column>
                                </filter-columns>
                            </filter-rule>
                        </filter-rules>
                        <value-check-rules>
                            <value-check-rule column-name="name">
                                <expression>
                                    <![CDATA[
                                        #name#.contains("sds")
                                    ]]>
                                </expression>
                            </value-check-rule>
                        </value-check-rules>
                    </insert-rule>

上面的这段配置表示,对于访客来说,插入数据的时候,custom_group字段和age字段是不可以被插入的,即使程序在写的时候有指定值,那么这两个字段也会被忽略。

另外,也对插入的值进行了安全检查,上面的例子中定义了要插入的名字字段中必须包含"sds"字符串。

public void testValueCheck() throws SQLException {
        try{
            //'asda'不符合条件
            stmt.execute("insert into custom (name,age) values ('asda',11)");
        }catch (DataAccessControlRuntimeException e){
            assertEquals("0TE120120001",e.getErrorCode().toString());
        }

        //多行插入测试
        try{
            stmt.execute(
                    "insert into custom (name,age,custom_group) values ('sdsaaa',19,''),('sdsbbb',20,'normal')");
        }catch (DataAccessControlRuntimeException e){
            assertEquals("0TE120120001",e.getErrorCode().toString());
        }
    }
上面是做测试用例,验证在插入一条或多条数据的时候,能不能对数据进行相关的合法性检查。

通过在数据访问权限中控制,会有一个非常好的地方,就是不管程序员用怎么编写,用什么框架编写,只要不符合规则,统统都会被拦住,绝对不会有一个漏网之鱼发生。

上面只是小试牛刀,接下来我们看看其它的语句的处理:

<access-control-rule>
            <table-rules>
                <table-rule table-name="custom">
                    <delete-rule>
                        <value-check-rules>
                            <value-check-rule column-name="name">
                                <expression>
                                    <![CDATA[
                                        #name#.contains("sds")
                                    ]]>
                                </expression>
                            </value-check-rule>
                        </value-check-rules>
                    </delete-rule>
                </table-rule>
            </table-rules>
        </access-control-rule>
上面定义了一条规则,意思是只有name字段的值是以sds开头的才可以删除。

preparedStatement = conn.prepareStatement("delete from custom where name=?");
        preparedStatement.setString(1,"aaaa");
        boolean isException = false;
        try {
            preparedStatement.execute();
        }catch (DataAccessControlRuntimeException e){
        }
        preparedStatement = conn.prepareStatement("delete from custom where name=?");
        preparedStatement.setString(1,"sd");
        try {
            preparedStatement.executeUpdate();
            fail();
        }catch (DataAccessControlRuntimeException e){
        }
        preparedStatement = conn.prepareStatement("delete from custom where name=?");
        preparedStatement.setString(1,"sds");
        try {
            preparedStatement.executeUpdate();
        }catch (DataAccessControlRuntimeException e){
            fail()
        }
从上面可以看到,只有满足条件的时候,才可以正确的删除成功,否则就会抛出数据访问异常。

当然,我们在处理的时候,有行权限和列权限。对于行权限,实际上是通过附加条件的方式进行数据过滤(SELECT)或者直接抛出异常的方式(INSERT、DELETE、UPDATE)。对于列权限则有两种处理方式,一种是当SELECT的时候,有些列没有权限时,就直接显示NULL,另外一种情况是,当没有权限访问的时候,把该列置为用记期望的某个值。

<filter-rules>
	<filter-rule>
	<expression>
		<![CDATA[
                            #age#<20
                        ]]>
	</expression>
	<filter-columns>
		<filter-column column-name="age" value="20"></filter-column>
	</filter-columns>
 </filter-rule>
	<filter-rule>
		<expression>
			<![CDATA[
                            #age#>20
                        ]]>
		</expression>
		<filter-columns>
			<filter-column column-name="age" value="1000"></filter-column>
		</filter-columns>
	</filter-rule>
</filter-rules>



上面的含义是当age<20的时候,就把age列返回20;如果年龄大于20,则age列返回1000。这里只是一个示例了,不要太纠结于为什么这里是设置为20而不是30,实际上你可以设置成任意的值,比如-1,用来表示你是没有仅限查看,这样在界面上显示的时候,就可以做相应的处理了:#if(user.age==-1)无权查看#end

如果不指定value属性,则会被返回为NULL。

public void testWithSchema() throws SQLException {
		//准备测试数据
		stmt.execute("insert into custom(id,age,name) values(1,11,'aaa')");
		stmt.execute("insert into custom(id,age,name) values(2,50,'bbb')");

		//测试
		Context context = new ContextImpl();
		//上下文中放入年龄,不为空的情况下配置condition为【"age>"+#s_age#】
		context.put("s_age", 30);//where子句中增加【cus.age>30】

		dataAccessControlContext.setThreadContext(context);

		//最终sql SELECT custom.id, custom.name, custom.age FROM custom WHERE  age>30
		//H2数据库默认schema是PUBLIC
		ResultSet rs = stmt.executeQuery("select PUBLIC.custom.id,PUBLIC.custom.name,custom.age FROM PUBLIC.custom");
		resultAssert(rs,new Object[][]{{"bbb",1000}});
	}
如上,我们在数据库中插入的是11和50,但是在查询时,本来是50的值的时候,结果返回的反而是1000。

上面的示例中,可以看到我们是向contex中放了一个值,实际上你可以放置任意的KV,实际应用中,应该放置的是和用户相关的一些数据对象,这样就可以编写和用户相关的表达式了。

当然,实际上应用过程,应用场景会非常复杂,比如有:

select name as n from user as u where u.n=='123'之类的写法,当然对于TinyDAC来说,还是可以来者通吃,全部正确的支持掉。

总之一句话,Tiny的原则就是牺牲自己,愉悦程序员。只有你想不到的,少有我们不支持的。

Between,支持:

public void testBetween() throws SQLException {
		stmt.execute("insert into custom(id,age,name) values(1,19,'aaa')");
		stmt.execute("insert into custom(id,age,name) values(2,31,'bbb')");
		stmt.execute("insert into custom(id,age,name) values(3,40,'ccc')");
		stmt.execute("insert into custom(id,age,name) values(4,50,'ddd')");

		Context context = new ContextImpl();
		dataAccessControlContext.clearAllValues();
		//上下文中放入年龄,不为空的情况下配置condition为【"age>"+#s_age#】
		context.put("s_age", 30);//where子句中增加【cus.age>30】

		dataAccessControlContext.setThreadContext(context);

		//真正sql SELECT id, name FROM custom WHERE age BETWEEN 31 AND 40 and age>30
		ResultSet rs = stmt.executeQuery("select id,name from custom where age between 31 and 40");
		resultAssert(rs,new Object[][]{{"bbb"},{"ccc"}});
	}
distinct支持:

public void testDistinct() throws SQLException {
		//准备测试数据
		stmt.execute("insert into custom(id,age,name) values(1,28,'aaa')");
		stmt.execute("insert into custom(id,age,name) values(2,32,'bbb')");
		stmt.execute("insert into custom(id,age,name) values(3,31,'ccc')");
		stmt.execute("insert into custom(id,age,name) values(4,31,'ddd')");

		Context context = new ContextImpl();
		//上下文中放入年龄,不为空的情况下配置condition为【"age>"+#s_age#】
		context.put("s_age", 30);//where子句中增加【cus.age>30】

		dataAccessControlContext.setThreadContext(context);

		//最终sql SELECT DISTINCT age FROM custom WHERE  age>30]
		ResultSet rs = stmt.executeQuery("select distinct age from custom");
		Object[][] expectedResult = new Object[][]{{1000},{1000}};
		int i=0;
		while (rs.next()){
			assertEquals(rs.getObject(1),expectedResult[i++][0]);
		}
		assertEquals(i,expectedResult.length);//确保有数据
	}
函数依旧支持:

/**
	 * concat 函数
	 * 格式 select CONCAT(aaa,'sds')  from ddltest;
	 */
	public void testConCat() throws SQLException {
		//准备测试数据
		stmt.execute("insert into custom(id,age,name) values(1,28,'aaa')");
		stmt.execute("insert into custom(id,age,name) values(2,32,'bbb')");
		stmt.execute("insert into custom(id,age,name) values(3,31,'ccc')");
		stmt.execute("insert into custom(id,age,name) values(4,31,'ddd')");

		Context context = new ContextImpl();
		dataAccessControlContext.clearAllValues();
		//上下文中放入年龄,不为空的情况下配置condition为【"age>"+#s_age#】
		context.put("s_age", 30);//where子句中增加【age>30】

		dataAccessControlContext.setThreadContext(context);

		ResultSet rs = stmt.executeQuery("select id,concat(name,'eee'),age from custom");
		resultAssert(rs,new Object[][]{{"bbbeee",1000},{"ccceee",1000},{"dddeee",1000}});

	}



分页:

/**
     * 格式 SELECT * FROM mytable WHERE mytable.col = 9 LIMIT 3, ?
     * 格式 SELECT * FROM mytable WHERE mytable.col = 9 LIMIT ? OFFSET 3
     */
    public void testLimit() throws SQLException {
        //准备测试数据
        stmt.execute("insert into custom(id,age,name) values(1,28,'aaa')");
        stmt.execute("insert into custom(id,age,name) values(2,29,'bbb')");
        stmt.execute("insert into custom(id,age,name) values(3,32,'ccc')");
        stmt.execute("insert into custom(id,age,name) values(4,31,'ddd')");
        stmt.execute("insert into custom(id,age,name) values(5,32,'eee')");
        stmt.execute("insert into custom(id,age,name) values(6,33,'fff')");
        stmt.execute("insert into custom(id,age,name) values(7,34,'ggg')");
        stmt.execute("insert into custom(id,age,name) values(8,35,'hhh')");
        stmt.execute("insert into custom(id,age,name) values(9,36,'iii')");
        stmt.execute("insert into custom(id,age,name) values(10,37,'jjj')");
        stmt.execute("insert into custom(id,age,name) values(11,38,'kkk')");
        stmt.execute("insert into custom(id,age,name) values(12,39,'lll')");

        //实际sql【SELECT age, cus.name FROM custom_view cus WHERE id > 1 LIMIT 5 OFFSET 0】
        ResultSet rs = stmt.executeQuery("select age,cus.name from custom_view cus where id>1 limit 0,5");
        resultAssert(rs,new Object[][]{{"bbb",1000},{"ccc",1000},{"ddd",1000},{"eee",1000},{"fff",1000}});

        rs = stmt.executeQuery("select age,cus.name from custom_view cus where id>1 order by id desc limit 8,4");
        resultAssert(rs,new Object[][]{{"ddd",1000},{"ccc",1000},{"bbb",1000}});

        Context context = new ContextImpl();
        //上下文中放入年龄,不为空的情况下配置condition为【"age>"+#s_age#】
        context.put("s_age", 30);//where子句中增加【cus.age>30】

        dataAccessControlContext.setThreadContext(context);

        //实际sql SELECT age, cus.name FROM custom_view cus WHERE id > 1 and cus.age>30 ORDER BY id DESC LIMIT 4 OFFSET 8
        rs = stmt.executeQuery("select age,cus.name from custom_view cus where id>1 order by id desc limit 8,4");
        resultAssert(rs,new Object[][]{{"ddd",1000},{"ccc",1000}});
    }
至此,我们就对数据访问权限有了一个完整的实现,由于测试用例实在太多,有100多个,没有办法一一展示,但是通过上面的一点示例大概可以看出一点意思了。

确实,这个问题是一个非常难于解决的问题,我们通过艰苦努力,终于有了一个解,还是一个不错的解。

好的架构师就是把不可能变成可能,把可能变成简单。

Q&A:

1.这个框架有侵入性么?

没有任何侵入性,可以在任何的JAVA环境中使用。

2.对JDK版本有要求么?

JDK1.5、6、7、8都可以。

3.原有的集成此框架有哪些改造的?

把原来写的行、列权限的硬变码变成配置,做个切面把用户相关的对象,放到ThreadLocal当中去。

4.我们原来用的是Ibatis,Hibernate,Spring JDBC Tempalte,etc,可以和这个框架集成么?

没有任何问题,只要是低层基于JDBC的数据库访问方式全支持。

5.支持非关系型数据库么?

理论上也可以支持,但是目前我们只支持关系型数据库。

6.这个框架会开源么?

嗯,这是一个选项,但是目前还没有开源。



转载于:https://my.oschina.net/tinyframework/blog/653256

发布了158 篇原创文章 · 获赞 44 · 访问量 19万+
展开阅读全文
评论将由博主筛选后显示,对所有人可见 | 还能输入1000个字符

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览