信息系统之数据权限的实现

前言

信息系统中权限管理是最核心的部分,系统的好与坏取决于权限管是否灵活,权限控制是否全面,权限范围是否颗粒化。这里我与大家分享一下数据权限的管控经验,并借此抛砖引玉。

新的需求

起初,业务需求简单, 我们的系统使用是基础RABC模式来管理权限:

java数据控权设计_runtime

而现在的对数据的权限管理提出了新的需求,现假设以下需求,来说明系统数据权限的实现: 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
    }
}

备注:

  1. master描述的是权限的拥有者,它可以是角色,角色组,还是可以是具体的用户
  2. masterValue是拥有者的Id
  3. access描述的是该权限对应的资源,有菜单,url, 数据,文件等
  4. accessValue则资源的Id
  5. 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_ROLEp.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件事需要了解一下:

  1. 给数据加个标签
  2. 新需求的实现思路
  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);
}
  • 新需求的实现思路
  1. 项目负责人对项目有增删改查的权限:权限“增”需要管理员指定哪类角色对哪数据有“增加”权限,鉴权由hasCreateDataPrivilege方法实现; 权限“删“与”改”,是在项目创建之后对项目的操作,可以是由项目负责人指定或管理员指定,鉴权分别是hasEditDataPrivilegehasDeleteDataPrivilege方法;而查看权限其实只要根据用户的Privilege将项目过滤就可以了,这里只简单实现hasViewDataPrivilege方法。
  2. 项目成员对项目则有修改与查看的权限,项目成员由项目负责人指定 :只能对参与的项目拥有权限,对其它项目没有权限。当项目负责人将DataMark标记了userId时,该项目为个人数据。当项目负责人指定了项目成员,项目成员这类个人数据也有管理权限。
  3. 主管对部门下所有项目有查看权限,经理则可以对所有部门的所有项目有查看权限:此需求只针对公共数据,由管理员授予权限。而对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 {
    }
}

备注:

  1. 除了以上代码外,我们还在ProjectController中相应的方法上标上了注解@CreateAuth
  2. 拦截器拦截了Client发过来的request,得到了clazz(控制器类)及method,再根据是否标注了@CreateAuth, 进行hasCreateDataPrivilege数据权限鉴别。
  3. user 与 organizationId是可以通过自动注入当前登录用户,这里为了只为了说明使用拦截器如何进行鉴权

以上代码并不完整,但实现数据权限的实现思路基本写完了,通过CreateAuthInterceptor实现Spring的拦截器,有如上拦截器原理图,它是需要按顺序注册的,它将会放在LoginAuthInterceptor的后面注册。

后话: 可能有人会说,虽然实现了数据的增删改查权限的掌控,但权限范围还没到颗粒化地程序,有的业务需求如Excel导入导出,报表图表的生成,都要求权限管理的,这又怎么实现呢?我们就需要对URL进行鉴权了,通过Action模型,然后利用反射收集标上@ActionAuth的注解的Controller与method,再与Privilege关联来达到权限的管理,原理同上。