示例项目
Casbin 官方文档提供了一个 Spring 项目实现菜单权限的示例项目: https://github.com/jcasbin/jcasbin-menu-permission
下面我们就基于这个示例项目,解读如何基于 casbin 实现菜单权限
访问控制策略模型
定义元模型
- 源文件:
src/main/resources/casbin/model.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
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/denyg关联用户与角色;支持角色继承(如ROLE_ADMIN继承ROLE_USER)g2建树:子菜单→父菜单;(NULL)仅声明顶级菜单
实现 Spring 菜单权限
执行器注入
Enforcer 是Casbin 的执行器, CasbinConfig 类定义了启动时注入 Enforcer,统一由 Spring 管理。
- 源文件:
src/main/java/org/casbin/config/CasbinConfig.java:33–38
@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
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
@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
@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
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,以便集中管理与多实例策略同步
- 在服务层统一封装权限判断与菜单过滤,控制器与视图只关注数据与展示,保持职责清晰
