RBAC(Role-Based Access Control,基于角色的访问控制),就是用户通过角色与权限进行关联。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间,一般者是多对多的关系。
当用户的数量非常大时,要给系统每个用户逐一授权(授角色),是件非常烦琐的事情。这时,就需要给用户分组,每个用户组内有多个用户。除了可给用户授权外,还可以给用户组授权。这样一来,用户拥有的所有权限,就是用户个人拥有的权限与该用户所在用户组拥有的权限之和。(下图为用户组、用户与角色三者的关联关系)
在应用系统中,权限表现成什么?对功能模块的操作,对上传文件的删改,菜单的访问,甚至页面上某个按钮、某个图片的可见性控制,都可属于权限的范畴。有些权限设计,会把功能操作作为一类,而把文件、菜单、页面元素等作为另一类,这样构成“用户-角色-权限-资源”的授权模型。而在做数据表建模时,可把功能操作和资源统一管理,也就是都直接与权限表进行关联,这样可能更具便捷性和易扩展性。(见下图)
请留意权限表中有一列“权限类型”,我们根据它的取值来区分是哪一类权限,如“MENU”表示菜单的访问权限、“OPERATION”表示功能模块的操作权限、“FILE”表示文件的修改权限、“ELEMENT”表示页面元素的可见性控制等。
这样设计的好处有二。其一,不需要区分哪些是权限操作,哪些是资源,(实际上,有时候也不好区分,如菜单,把它理解为资源呢还是功能模块权限呢?)。其二,方便扩展,当系统要对新的东西进行权限控制时,我只需要建立一个新的关联表“权限XX关联表”,并确定这类权限的权限类型字符串。
这里要注意的是,权限表与权限菜单关联表、权限菜单关联表与菜单表都是一对一的关系。(文件、页面权限点、功能操作等同理)。也就是每添加一个菜单,就得同时往这三个表中各插入一条记录。这样,可以不需要权限菜单关联表,让权限表与菜单表直接关联,此时,须在权限表中新增一列用来保存菜单的ID,权限表通过“权限类型”和这个ID来区分是种类型下的哪条记录。
到这里,RBAC权限模型的扩展模型的完整设计图如下:
随着系统的日益庞大,为了方便管理,可引入角色组对角色进行分类管理,跟用户组不同,角色组不参与授权。例如:某电网系统的权限管理模块中,角色就是挂在区局下,而区局在这里可当作角色组,它不参于权限分配。另外,为方便上面各主表自身的管理与查找,可采用树型结构,如菜单树、功能树等,当然这些可不需要参于权限分配。
以上,是从基本的RBAC模型进行了扩展,具体的设计要根据项目业务的需要作调整。
设计用户组是否必要:
有人认为设计用户组时还需要为用户添加用户组以及为用户组添加权限,这和直接对单个用户添加权限异曲同工.但是当需要给已经存在的用户赋予权限时,如果之前使用了用户组这样的设计模式,那么便可以直接在用户组中赋予权限,不必去给每个用户赋予权限.而且使用用户组也是体现用户层级关系的一种结构,所以个人认为使用用户组是有必要的.
角色、权限在逻辑上是树形结构,对角色权限的增删改查操作就要涉及到对树形数据结构的操作。本文以删除某一角色为例探讨实际开发过程中用到的几种对树形结构操作的方法:
如图所示,删除某一角色需要以下几个步骤:
1:查询角色及其子角色列表,得角色及其子角色id列表;
2:根据第一步查的得角色id列表查询用户角色映射表,判断该角色及其子角色是否分配给了用户
如果是,则不能删除,如果为否则进行下一步操作;
3:删除角色及其子角色;
4:根据第一步查询的角色Id列表删除角色权限表中相应的数据。
其中第一步查询树形结构的角色及其子角色列表是最关键的部分,本文将介绍以下6种方式查询树形结构:
1:SQL递归查询:
为方便后续说明,在此统一约定表名为:t_org
,其定义如下:
字段 | 类型 | 说明 |
---|---|---|
id | bigint(20) NOT NULL | 机构编码 |
parent_id | bigint(20) | 上级机构编码 |
desc | varchar(200) | 备注 |
查询实现方案
表中现有如下测试数据
测试数据
组织机构层级数确定时
可以采用自关联LEFT JOIN
方式进行查询获取结果。
SELECT t1.id,t1.name,t2.id,t2.parent_id,t2.name,t3.id,t3.parent_id,t3.name
FROM t_org t1
LEFT JOIN t_org t2 ON t1.id = t2.parent_id
LEFT JOIN t_org t3 ON t2.id = t3.parent_id
WHERE t1.id = '1';
查询结果如下;
当组织机构层级数不确定时,无法使用上述方式进行查询
可以通过自定义函数方式实现查询
CREATE DEFINER=`root`@`localhost` FUNCTION `findChildren`(rootId INT) RETURNS VARCHAR(4000) CHARSET utf8
BEGIN
DECLARE sTemp VARCHAR(4000);
DECLARE sTempChd VARCHAR(4000);
SET sTemp = '$';
SET sTempChd = CAST(rootId as CHAR);
WHILE sTempChd is not null DO
SET sTemp = CONCAT(sTemp,',',sTempChd);
SELECT GROUP_CONCAT(id) INTO sTempChd FROM t_org
WHERE FIND_IN_SET(parent_id,sTempChd)>0;
END WHILE;
RETURN sTemp;
END;
在上面函数中使用到了两个MySQL函数
GROUP_CONCAT(expr)
该函数会从expr中连接所有非NULL的字符串。如果没有非 NULL 的字符串,那么它就会返回NULL。语法如下:
GROUP_CONCAT([DISTINCT] expr [,expr ...]
[ORDER BY {unsigned_integer | col_name | expr}
[ASC | DESC] [,col_name ...]]
[SEPARATOR str_val])
注意事项:GROUP_CONCAT查询结果默认最大长度限制为1024,该值是系统变量group_concat_max_len
的默认值,可以通过SET [GLOBAL | SESSION] group_concat_max_len = val;
更改该值。
FIND_IN_SET(str,strlist)
该函数返回一个1~N的值表示str
在strlist
中的位置。
该函数结合WHERE
使用对结果集进行过过滤(查找str包含在strlist结果集里面的记录)
函数使用方式
SELECT * FROM t_org
WHERE FIND_IN_SET(id,findChildren(1)) > 0;
方案缺点
返回结果长度受VARCHAR
最大长度限制,特别是当组织机构比较庞大时该方案会失效。下面我们可以使用存储过程结合临时表来解决这个问题。
存储过程+临时表
使用存储过程结合临时表的方案需要创建两个存储过程,一个用于递归查询所有节点并将数据写入临时表中,另一个负责创建临时表、清空临时表数据,触发查询调用动作。
首先,定义第一个存储过程,如下:
CREATE DEFINER=`root`@`localhost` PROCEDURE `findOrgChildList`(IN orgId VARCHAR(20))
BEGIN
DECLARE v_org VARCHAR(20) DEFAULT '';
DECLARE done INTEGER DEFAULT 0;
-- 查询结果放入游标中
DECLARE C_org CURSOR FOR SELECT d.id
FROM t_org d
WHERE d.parent_id = orgId;
DECLARE CONTINUE HANDLER FOR NOT found SET done=1;
SET @@max_sp_recursion_depth = 10;
-- 传入的组织id写入临时表
INSERT INTO tmp_org VALUES (orgId);
OPEN C_org;
FETCH C_org INTO v_org;
WHILE (done=0)
DO
-- 递归调用,查找下级
CALL findOrgChildList(v_org);
FETCH C_org INTO v_org;
END WHILE;
CLOSE C_org;
END
如上所示,逻辑比较简单。接下来定义第二个存储过程,如下;
CREATE DEFINER=`root`@`localhost` PROCEDURE `findOrgList`(IN orgId VARCHAR(20))
BEGIN
DROP TEMPORARY TABLE IF EXISTS tmp_org;
-- 创建临时表
CREATE TEMPORARY TABLE tmp_org(org_id VARCHAR(20));
-- 清空临时表数据
DELETE FROM tmp_org;
-- 发起调用
CALL findOrgChildList(orgId);
-- 从临时表查询结果
SELECT org_id FROM tmp_org ORDER BY org_id;
END
使用方式如下
CALL findOrgList(org_id);
至此,我们在可以处理无限层级的树形结构数据。
MyBatis调用存储过程
在MyBatis
中我们可以使用如下方式对存储过程进行调用
<select id="selectOrgChildList" resultType="java.lang.String" statementType="CALLABLE">
<![CDATA[
CALL findOrgList(
#{orgId,mode=IN,jdbcType=VARCHAR},
]]>
</select>
需要指定statementType
为CALLABLE
表示需要执行的是一个存储过程,statementType
默认值为PREPARED
。
2:先查出所有数据再递归
public class RoleTreeServiceImpl extends ServiceImpl<RoleTreeMapper,RoleTree>implements RoleTreeService{
@Autowired
private RoleTreeMapper roleTreeMapper;
@Autowired
private UserRoleMapper userRoleMapper;
/**
* 获取所有分类
* @return
*/
@Override
public List<RoleTree> selectRoleTree() {
List<RoleTree>roleTreeList=roleTreeMapper.selectRoleTree();
List<UserRole>userRoleList=userRoleMapper.selectAll();
//定义一个新的List
List<RoleTree>treeList=new ArrayList<>();
//找到所有的一级分类
for(RoleTree roleTree :roleTreeList){
//一级菜单的parent_role_id是0
if(roleTree.getParent_role_id()==0){
treeList.add(roleTree);
}
}
//为1级菜单设置子菜单
for (RoleTree roleTree :treeList){
roleTree.setTrees(getchilde(roleTree.getId(),roleTreeList));
}
return treeList;
}
/**
* 递归查找子菜单
* @param id 当前菜单id
* @param rootList 要查找的列表
*/
private List<RoleTree>getchilde(Integer id,List<RoleTree>rootList){
//子菜单的子菜单
List<RoleTree>childList =new ArrayList<>();
for (RoleTree roleTree :rootList){
//遍历所有节点,将父菜单id与传过来的id比较
if(roleTree.getParent_role_id().equals(id)){
childList.add(roleTree);
}
}
//将子菜单的子菜单再做循环
for(RoleTree roleTree :childList){
roleTree.setTrees(getchilde(roleTree.getId(),rootList));
}
//退出递归
if (childList.size()==0){
return null;
}
return childList;
}
}
3:递归查询数据库
private Long @Nullable [] getChildren2(@NotNull Role parentRole) {
var children = this.roleService.list(new QueryWrapper<Role>().eq("parent_id", parentRole.getId()));
if (children.size() > 0) {
children.forEach(role -> {
role.setAuthids(this.getChildren2(role));
});
return children.stream().map(Role::getId).toArray(Long[]::new);
}
return null;
}
4:通过设计ROLE_CODE实现
数据库中增加ROLE_CODE字段,新增角色的时候定义如下规则,根节点的code为A,子节点为A_B,子子节点为A_B_C,以此类推,查询某一节点的所有子节点时,根据子节点的ROLE_CODE值是否包含该节点的ROLE_CODE值即可查出该节点的所有子节点。
5:递归删除
@Override
public Result deleteById(String id) {
if (StringUtils.isEmpty(id)) {
return Result.error("");
}
// 要删除的所有id
List<String> ids = new ArrayList<>();
// 将当前id放入集合中
ids.add(id);
// 递归所有的id,并将id装到要删除集合中
this.getIds(ids, id);
// 批量删除集合中的id
baseMapper.deleteBatchIds(ids);
return Result.ok();
}
private void getIds(List<String> ids, String parentId) {
// 查询子信息
QueryWrapper<Comment> wrapper = new QueryWrapper<>();
wrapper.eq("parent_id", parentId);
List<Comment> commentList = baseMapper.selectList(wrapper);
if (CollectionUtils.isNotEmpty(commentList)) {
for (Comment comment : commentList) {
String id = comment.getId();
ids.add(id);
this.getIds(ids, id);
}
}
}
6:使用mybatis实现递归查询
<resultMap id="BaseResultMap" type="org.sang.bean.Department">
<id property="id" column="id"/>
<result column="name" property="name"/>
<result column="parentId" property="parentId"/>
<result column="isParent" property="isParent"/>
<collection property="children" ofType="org.sang.bean.Department" select="org.sang.mapper.DepartmentMapper.getDepByPid" column="id">
</collection>
</resultMap>
<select id="getDepByPid" resultMap="BaseResultMap">
select d1.*from department d1 where d1.`parentId`=#{pid} AND d1.enabled=true;
</select>
每一个Department中都有一个children属性,getDepByPid方法的返回结果是一个BaseResultMap,BaseResultMap中的collection又将调用getDepByPid方法,通过这种方式我们可以快速实现一个递归调用。Mapper中只需要定义如下方法即可
List<Department> getDepByPid(Long pid);
总结
方法一适用于节点层级固定的树形结构,若要层级不固定的树形结构查询,则需要自定义mysql函数,相对比较复杂,Oracle对此有相关实现。方法二实现相对简单,也是普遍使用的一种方式。方法三适用于数据量少的情况。方法四从设计上解决此问题,直观简单,但在新增节点的时候需要额外的ROLE_CODE字段,需要定义好规则。方法五用于递归删除,方法六:mybatis实现,推荐使用。