解读 Casbin 之二:如何在 Spring项目中实现菜单权限

示例项目

Casbin 官方文档提供了一个 Spring 项目实现菜单权限的示例项目: github.com/jcasbin/jca...

下面我们就基于这个示例项目,解读如何基于 casbin 实现菜单权限

访问控制策略模型

定义元模型

  • 源文件:src/main/resources/casbin/model.conf
conf 复制代码
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act, eft

[role_definition]
g = _, _
g2 = _, _

[policy_effect]
e = some(where (p_eft == allow)) && !some(where (p_eft == deny))

[matchers]
m = g(r.sub, p.sub) && r.act == p.act && (g2(r.obj, p.obj) || r.obj == p.obj)
  • role_definition 中使用了两种角色, g 用于管理用户-角色归属, g2 用于表达菜单父子层级,特别是g2的这种多层级的归属关系,可以有助我们更深入理解 casbin 的工作机制
  • policy_effect 定义了 deny 优先于 allow,用于精准否决具体子菜单
  • matchers 的匹配器允许"授权到父菜单→子菜单自动继承",或直接命中具体菜单

定义策略

  • 源文件:src/main/resources/casbin/policy.csv
csv 复制代码
p, ROLE_ROOT, SystemMenu, read, allow
p, ROLE_ADMIN, UserMenu, read, allow
p, ROLE_ADMIN, AdminSubMenu_deny, read, deny
p, ROLE_USER, UserSubMenu_allow, read, allow

g, admin, ROLE_ADMIN
g, user, ROLE_USER
g, ROLE_ADMIN, ROLE_USER

g2, UserSubMenu_allow, UserMenu
g2, AdminSubMenu_deny, AdminMenu
g2, (NULL), SystemMenu
  • p 为角色授予菜单访问;eft 为 allow/deny
  • g 关联用户与角色;支持角色继承(如 ROLE_ADMIN 继承 ROLE_USER
  • g2 建树:子菜单→父菜单;(NULL) 仅声明顶级菜单

实现 Spring 菜单权限

执行器注入

Enforcer 是Casbin 的执行器, CasbinConfig 类定义了启动时注入 Enforcer,统一由 Spring 管理。

  • 源文件:src/main/java/org/casbin/config/CasbinConfig.java:33--38
java 复制代码
@Configuration
public class CasbinConfig {
    @Bean
    public Enforcer enforcer() throws IOException {
        File modelFile = ResourceUtil.getTempFileFromResource("casbin/model.conf");
        File policyFile = ResourceUtil.getTempFileFromResource("casbin/policy.csv");
        return new Enforcer(modelFile.getAbsolutePath(), policyFile.getAbsolutePath());
    }
}

构建菜单数据结构

MenuUtil 仅解析策略中的 g2 记录,构建菜单树与父子关系。提供了一个字典menuMap来索引所有的菜单项,并给菜单设置了正确的父子关联。后续可用这个数据结构实现,拿到父级菜单权限即拿到子菜单。

  • 源文件:src/main/java/org/casbin/util/MenuUtil.java:20--56
java 复制代码
public class MenuUtil {
    public static Map<String, MenuEntity> parseCsvFile(String filePath) throws IOException {
        Map<String, MenuEntity> menuMap = new HashMap<>();
        try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
            String line;
            while ((line = br.readLine()) != null) {
                String[] values = line.split(",");
                if (values.length == 3 && "g2".equals(values[0].trim())) {
                    String childName = values[1].trim();
                    String parentName = values[2].trim();
                    if (!"(NULL)".equals(childName)) {
                        menuMap.putIfAbsent(childName, new MenuEntity(childName));
                        if (!parentName.isEmpty()) {
                            menuMap.putIfAbsent(parentName, new MenuEntity(parentName));
                            MenuEntity childMenu = menuMap.get(childName);
                            MenuEntity parentMenu = menuMap.get(parentName);
                            parentMenu.addSubMenu(childMenu);
                        }
                    } else if (!parentName.isEmpty()) {
                        menuMap.putIfAbsent(parentName, new MenuEntity(parentName));
                    }
                }
            }
        }
        return menuMap;
    }
}

权限过滤

MenuService 提供了一个方法findAccessibleMenus,用于获得当前用户拥有权限的所有菜单。checkAndSetMenuAccess方法使用递归算法过滤不可访问子节点;父节点只要自身或任一子节点可访问即"可见"

  • 源文件:src/main/java/org/casbin/service/MenuService.java:45--102
java 复制代码
@Service
@Slf4j
public class MenuService {
    @Autowired
    private Enforcer enforcer;
    private Map<String, MenuEntity> menuMap;
    private Map<String, Boolean> accessMap;

    public List<MenuEntity> findAccessibleMenus(String username) {
        try {
            File policyFile = ResourceUtil.getTempFileFromResource("casbin/policy.csv");
            this.menuMap = MenuUtil.parseCsvFile(policyFile.getAbsolutePath());
        } catch (IOException e) {
            this.menuMap = new HashMap<>();
        }
        this.accessMap = new HashMap<>();

        List<MenuEntity> accessibleMenus = new ArrayList<>();
        for (MenuEntity menu : menuMap.values()) { checkAndSetMenuAccess(menu, username); }
        for (MenuEntity menu : menuMap.values()) {
            if (isTopLevelMenu(menu) && accessMap.getOrDefault(menu.getName(), false)) {
                filterAndAddMenu(menu, accessibleMenus);
            }
        }
        return accessibleMenus;
    }

    private void checkAndSetMenuAccess(MenuEntity menu, String username) {
        boolean hasAccess = checkUserAccess(username, menu.getName());
        for (MenuEntity child : new ArrayList<>(menu.getSubMenus())) {
            accessMap.remove(child.getName());
            checkAndSetMenuAccess(child, username);
            if (!accessMap.getOrDefault(child.getName(), false)) {
                menu.getSubMenus().remove(child);
            } else {
                hasAccess = true;
            }
        }
        accessMap.put(menu.getName(), hasAccess);
    }

    private boolean checkUserAccess(String username, String menuName) {
        return enforcer.enforce(username, menuName, "read");
    }
}

路由拦截

WebMvcConfig 中统一对 /menu/* 做页级权限校验;未登录或无权限统一跳转拒绝页/denied

  • 源文件:src/main/java/org/casbin/config/WebMvcConfig.java:37--67
java 复制代码
@Slf4j
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private MenuService menuService;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**").excludePathPatterns("/login", "/");

        registry.addInterceptor(new HandlerInterceptor() {
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                HttpSession session = request.getSession();
                String username = (String) session.getAttribute("username");
                String requestURI = request.getRequestURI();
                String menuName = requestURI.substring(requestURI.lastIndexOf('/') + 1);

                if (username == null) {
                    response.sendRedirect(request.getContextPath() + "/denied");
                    return false;
                }
                if (!menuService.checkMenuAccess(username, menuName)) {
                    response.sendRedirect(request.getContextPath() + "/denied");
                    return false;
                }
                return true;
            }
        }).addPathPatterns("/menu/*");
    }
}

单元测试(直观示例)

项目提供了单元测试可以直接验证模型与策略的授权/否决效果

  • 源文件:src/test/java/org/casbin/MenuTest.java
java 复制代码
Enforcer enforcer = new Enforcer("examples/casbin/model.conf","examples/casbin/policy.csv");
assertTrue(enforcer.enforce("ROLE_ROOT", "AdminMenu", "read"));
assertFalse(enforcer.enforce("ROLE_USER", "AdminMenu", "read"));

实战提示

  • deny 精准否决具体子菜单,父级仍可显示但不展示该子项
  • 生产中建议切换到数据库适配器并开启 Watcher,以便集中管理与多实例策略同步
  • 在服务层统一封装权限判断与菜单过滤,控制器与视图只关注数据与展示,保持职责清晰
相关推荐
Loo国昌17 分钟前
【LangChain1.0】第八阶段:文档处理工程(LangChain篇)
人工智能·后端·算法·语言模型·架构·langchain
vx_bisheyuange22 分钟前
基于SpringBoot的海鲜市场系统
java·spring boot·后端·毕业设计
李慕婉学姐1 小时前
【开题答辩过程】以《基于Spring Boot和大数据的医院挂号系统的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
大数据·spring boot·后端
源代码•宸2 小时前
Leetcode—3. 无重复字符的最长子串【中等】
经验分享·后端·算法·leetcode·面试·golang·string
0和1的舞者2 小时前
基于Spring的论坛系统-前置知识
java·后端·spring·系统·开发·知识
invicinble3 小时前
对于springboot
java·spring boot·后端
码界奇点4 小时前
基于Spring Boot与Vue的校园后台管理系统设计与实现
vue.js·spring boot·后端·毕业设计·源代码管理
爱编程的小庄4 小时前
Rust 发行版本及工具介绍
开发语言·后端·rust
Apifox.5 小时前
测试用例越堆越多?用 Apifox 测试套件让自动化回归更易维护
运维·前端·后端·测试工具·单元测试·自动化·测试用例
sunnyday04266 小时前
Nginx与Spring Cloud Gateway QPS统计全攻略
java·spring boot·后端·nginx