解读 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,以便集中管理与多实例策略同步
  • 在服务层统一封装权限判断与菜单过滤,控制器与视图只关注数据与展示,保持职责清晰
相关推荐
AskHarries1 小时前
文件上传系统
后端
止语Lab2 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活2 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困2 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn2 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530142 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908402 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金2 小时前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端
人间凡尔赛3 小时前
AI-Native 云原生架构:2026 年从容器编排到智能体编排的范式革命
后端·云原生·架构
IT_陈寒3 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端