信息系统之数据权限的实现
前言
信息系统中权限管理是最核心的部分,系统的好与坏取决于权限管是否灵活,权限控制是否全面,权限范围是否颗粒化。这里我与大家分享一下数据权限的管控经验,并借此抛砖引玉。
新的需求
起初,业务需求简单, 我们的系统使用是基础RABC模式来管理权限:
而现在的对数据的权限管理提出了新的需求,现假设以下需求,来说明系统数据权限的实现: 1、项目负责人对项目有增删改查的权限,而项目成员对项目则有修改与查看的权限,项目成员由项目负责人指定 2、主管对部门下所有项目有查看权限,经理则可以对所有部门的所有项目有查看权限
实现菜单权限管理
我通过用下面的模型来描述权限: 谁(用户或角色)对某个(或某类)资源 有 哪些 权限。 Privilege
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "infra_privilege")
public class Privilege extends BaseEntity {
@Enumerated(EnumType.STRING)
private MasterEnum master;
private Long masterValue;
@Enumerated(EnumType.STRING)
private AccessEnum access;
private Long accessValue
private String operation;
public enum MasterEnum {
Role,
User
}
public enum AccessEnum {
Menu,
Action,
Data,
File
}
public enum OperationEnum {
Enable,
Disable,
Create,
Edit,
Delete,
View
}
}
备注:
- master描述的是权限的拥有者,它可以是角色,角色组,还是可以是具体的用户
- masterValue是拥有者的Id
- access描述的是该权限对应的资源,有菜单,url, 数据,文件等
- accessValue则资源的Id
- operation是String类型,如“Create|Edit”表示有新增与编辑的权限
我们先来实现比较简单的菜单的授权与鉴权,再来实现数据授权与鉴权。
PrivilegeService
@Service
public class PrivilegeService {
@Autowired
private PrivilegeRepo privilegeRepo;
// 用于鉴别用户具有的菜单权限
public List<Privilege> getMenuPrivilege(User user) {
Assert.notNull(user, "user不能为空");
Assert.notNull(user.getId(), "userId不能为空");
Set<Role> roles = user.getRoles();
List<Long> ids = roles.stream().map(x -> x.getId()).collect(Collectors.toList());
return privilegeRepo.findMenuPrivilegeByRoles(ids);
}
// 用于后台管理
public List<Privilege> getMenuPrivilege(Role role) {
Assert.notNull(role, "role不能为空");
Assert.notNull(role.getId(), "roleId不能为空");
return privilegeRepo.findMenuPrivilegeByRole(role.getId());
}
// 用于对角色进行菜单授权
public void setRoleMenuPrivilege(Role role, Menu menu, Boolean bool) {
Privilege privilege = new Privilege();
privilege.setMaster(Privilege.MasterEnum.Role);
privilege.setMasterValue(role.getId());
privilege.setAccess(Privilege.AccessEnum.Menu);
privilege.setAccessValue(menu.getId());
privilege.setOperation(
bool
? Privilege.OperationEnum.Enable.getOperation()
: Privilege.OperationEnum.Disable.getOperation());
privilegeRepo.save(privilege);
}
备注:我们稍后再说如何通过拦截器来实现用户的鉴权的,在PrivilegeService
已经实现了对菜单的鉴别与授权的方法了,很简单地,授权菜单:roleId, menuId与operation一一对就行了;而怎么鉴别菜单权限呢,只要把p.master = MASTER_ROLE
与p.access = ACCESS_MENU
加到过滤条件就可以得相应的菜单了
@Query("select p from Privilege p where p.master = MASTER_ROLE and" +
" p.masterValue in ?1 and p.access = ACCESS_MENU")
List<Privilege> findMenuPrivilegeByRoles(List<Long> ids);
@Query("select p from Privilege p where p.master = MASTER_ROLE and" +
" p.masterValue = ?1 and p.access = ACCESS_MENU")
List<Privilege> findMenuPrivilegeByRole(Long id);
准备工作
在说明数据授权与鉴权之前,有3件事需要了解一下:
- 给数据加个标签
- 新需求的实现思路
- 拦截器的原理
- 给数据加个标签?这好比图书馆的藏书,书本上的标签不仅有分类,还区分可外借与不外借,那么怎么对数据作权限管控呢? 我们给数据加上了一个标签DataMark, 它有三个属性:数据所在的组织,数据的类型名称与数据的所有者。当dataMark.userId 为空时是公共数据,不为空时是个人数据;公共数据由管理员指定哪些角色对哪些DataMark有哪些权限;个人数据由数据创建者指定哪些用户有哪些权限;
@Entity
@Table(name = "infra_data_mark")
public class DataMark extends BaseEntity{
@Column(nullable = false)
@Getter @Setter
private Long organizationId;
@Column(nullable = false)
@Getter @Setter
private String modelName;
@Getter @Setter
private Long userId;
}
假设这里有一个项目模型, 这样我们就给这个Project
加上了标签了, 这个标签在这个Project
新增之前产生,并且在infra_data_mark
表中唯一。
@Entity
@Table
public class Project extends DataEntity{
@OneToOne
@JoinColumn
@Getter @Setter
private DataMark dataMark;
// 省略其它属性
}
接下来是PrivilegeService
。
@Service
public class PrivilegeService {
@Autowired
private PrivilegeRepo privilegeRepo;
public Boolean hasCreateDataPrivilege(User user, Long organizationId, String modelName) {
Assert.notNull(user, "user不能为空");
Assert.notNull(user.getId(), "userId不能为空");
Assert.notNull(organizationId, "dataMark不能为空");
Assert.hasText(modelName, "modelName不能为空");
DataMark dataMark = markService.markPublic(organizationId, modelName);
if (privilegeRepo.countRoleDataPrivilege(user.getRoleIds(), dataMark.getId(), Privilege.OperationEnum.Create.toString()) > 0) {
return true;
}
return false;
}
public Boolean hasEditDataPrivilege(User user, Long dataId, Long dataMarkId) {
Assert.notNull(user, "user不能为空");
Assert.notNull(user.getId(), "userId不能为空");
Assert.notNull(dataId, "dataId不能为空");
Assert.notNull(dataMarkId, "dataMarkId不能为空");
if (privilegeRepo.countUserDataPrivilege(user.getId(), dataId, Privilege.OperationEnum.Edit.toString()) > 0
|| privilegeRepo.countRoleDataPrivilege(user.getRoleIds(), dataMarkId, Privilege.OperationEnum.Edit.toString()) > 0) {
return true;
}
return false;
}
public Boolean hasDeleteDataPrivilege(User user, Long dataId, Long dataMarkId) {
Assert.notNull(user, "user不能为空");
Assert.notNull(user.getId(), "userId不能为空");
Assert.notNull(dataId, "dataId不能为空");
Assert.notNull(dataMarkId, "dataMarkId不能为空");
if (privilegeRepo.countUserDataPrivilege(user.getId(), dataId, Privilege.OperationEnum.Delete.toString()) > 0
|| privilegeRepo.countRoleDataPrivilege(user.getRoleIds(), dataMarkId, Privilege.OperationEnum.Delete.toString()) > 0) {
return true;
}
return false;
}
public Boolean hasViewDataPrivilege(User user, Long dataId, Long dataMarkId) {
Assert.notNull(user, "user不能为空");
Assert.notNull(user.getId(), "userId不能为空");
Assert.notNull(dataId, "dataId不能为空");
Assert.notNull(dataMarkId, "dataMarkId不能为空");
if (privilegeRepo.countUserDataPrivilege(user.getId(), dataId, Privilege.OperationEnum.View.toString()) > 0
|| privilegeRepo.countRoleDataPrivilege(user.getRoleIds(), dataMarkId, Privilege.OperationEnum.View.toString()) > 0) {
return true;
}
return false;
}
public List<DataAuthDto> getDataPrivilege(Role role) {
Assert.notNull(role, "role不能为空");
Assert.notNull(role.getId(), "roleId不能为空");
List<Privilege> privilegeList = privilegeRepo.findDataPrivilegeByRole(role.getId());
List<DataAuthDto> dtos = Lists.newArrayList();
for(Privilege privilege: privilegeList){
Boolean flag = false;
Long av = (Long)privilege.getAccessValue();
DataMark dataMark = markService.getMarkById(av);
for(DataAuthDto dto : dtos) {
if (dto.getOrganizationId() == dataMark.getOrganizationId() &&
dto.getModelName() == dataMark.getModelName()) {
dto.getOperations().add(privilege.getOperation());
flag = true;
}
}
if (!flag) {
DataAuthDto dto = new DataAuthDto();
dto.setModelName(dataMark.getModelName());
dto.setOrganizationId(dataMark.getOrganizationId());
dto.getOperations().add(privilege.getOperation());
dtos.add(dto);
}
}
return dtos;
}
public void setRoleDataPrivilege(Role role, Long dataMarkId, String operation) {
if (privilegeRepo.countDataPrivilege(role.getId(), dataMarkId, operation) == 0) {
Privilege privilege = new Privilege();
privilege.setMaster(Privilege.MasterEnum.Role);
privilege.setMasterValue(role.getId());
privilege.setAccess(Privilege.AccessEnum.Data);
privilege.setAccessValue(dataMarkId);
privilege.setOperation(operation);
privilegeRepo.save(privilege);
}
}
public void setUserDataPrivilege(User user, Long dataId, String operation) {
if (privilegeRepo.countDataPrivilege(user.getId(), dataId, operation) == 0) {
Privilege privilege = new Privilege();
privilege.setMaster(Privilege.MasterEnum.User);
privilege.setMasterValue(user.getId());
privilege.setAccess(Privilege.AccessEnum.Data);
privilege.setAccessValue(dataId);
privilege.setOperation(operation);
privilegeRepo.save(privilege);
}
}
}
PrivilegeRepo
部分代码
public interface PrivilegeRepo extends JpaRepository<Privilege, Long> {
@Query("select count(p) from Privilege p where p.masterValue = ?1 and p.accessValue = ?2 and p.operation = ?3")
int countDataPrivilege(long masterValue, long accessValue, String operation);
@Query("select count(p) from Privilege p where p.master = MASTER_ROLE and p.masterValue in ?1 " +
"and p.access = ACCESS_DATA and p.accessValue = ?2 and p.operation like %?3%")
int countRoleDataPrivilege(Set<Long> masterValue, long accessValue, String operation);
}
- 新需求的实现思路
- 项目负责人对项目有增删改查的权限:权限“增”需要管理员指定哪类角色对哪数据有“增加”权限,鉴权由
hasCreateDataPrivilege
方法实现; 权限“删“与”改”,是在项目创建之后对项目的操作,可以是由项目负责人指定或管理员指定,鉴权分别是hasEditDataPrivilege
、hasDeleteDataPrivilege
方法;而查看权限其实只要根据用户的Privilege将项目过滤就可以了,这里只简单实现hasViewDataPrivilege
方法。 - 项目成员对项目则有修改与查看的权限,项目成员由项目负责人指定 :只能对参与的项目拥有权限,对其它项目没有权限。当项目负责人将DataMark标记了userId时,该项目为个人数据。当项目负责人指定了项目成员,项目成员这类个人数据也有管理权限。
- 主管对部门下所有项目有查看权限,经理则可以对所有部门的所有项目有查看权限:此需求只针对公共数据,由管理员授予权限。而对DataMark.userId不为空的项目(个人数据)没有管理权限。
- 拦截器的原理 先来了解一下spring中的拦截器原理,由于篇幅与精力有限,这里就附上一张网络找来的拦截器的原理图
实现授权与鉴权
- 授权。简单地说,只要将User或Role,与DataMark,通过Privilege联系起来。根据业务需求,可能还有RoleGroup,甚至一些Url(后面会提到Action权限的管理),也是通过Privilege联系起来。
- 鉴权。我们需要对数据的增删改查进行权限管控。 首先,实现四个注解式接口:
@CreateAuth
、@EditAuth
、@DeleteAuth
、@ViewAuth
, 其中@CreateAuth
如下,其它大致相同
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface CreateAuth {
String[] value() default "";
}
然后,实现CreateAuthInterceptor
, 它实现了HandlerInterceptor
的接口的方法,再把它按需求顺序注册到系统中。
@Slf4j
public class CreateAuthInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private PrivilegeService privilegeService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
final HandlerMethod handlerMethod = (HandlerMethod) handler;
final Class<?> clazz = handlerMethod.getBeanType();
final Method method = handlerMethod.getMethod();
if (clazz.isAnnotationPresent(CreateAuth.class) || method.isAnnotationPresent(CreateAuth.class)) {
// 取得当前登录用户
User user = userService.getUserById((Long)request.getAttribute("userId"));
Organization organ = user.getOrganization();
String modelName = clazz.getSimpleName().split("Controller")[0];
if (privilegeService.hasCreateDataPrivilege(user, organ.getId, modelName) {
log.info("CreateAuthInterceptor: CreateAuth ok");
return true;
}
return false;
}else {
log.info("CreateAuthInterceptor: CreateAuth failed");
return false;
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
备注:
- 除了以上代码外,我们还在
ProjectController
中相应的方法上标上了注解@CreateAuth
- 拦截器拦截了
Client
发过来的request
,得到了clazz
(控制器类)及method
,再根据是否标注了@CreateAuth
, 进行hasCreateDataPrivilege
数据权限鉴别。 - user 与 organizationId是可以通过自动注入当前登录用户,这里为了只为了说明使用拦截器如何进行鉴权
以上代码并不完整,但实现数据权限的实现思路基本写完了,通过CreateAuthInterceptor
实现Spring的拦截器,有如上拦截器原理图,它是需要按顺序注册的,它将会放在LoginAuthInterceptor
的后面注册。
后话: 可能有人会说,虽然实现了数据的增删改查权限的掌控,但权限范围还没到颗粒化地程序,有的业务需求如Excel导入导出,报表图表的生成,都要求权限管理的,这又怎么实现呢?我们就需要对URL进行鉴权了,通过Action模型,然后利用反射收集标上@ActionAuth的注解的Controller与method,再与Privilege关联来达到权限的管理,原理同上。