diff --git a/src/main/java/org/casbin/jcasbin/detector/DefaultDetector.java b/src/main/java/org/casbin/jcasbin/detector/DefaultDetector.java new file mode 100644 index 00000000..bde5fb5a --- /dev/null +++ b/src/main/java/org/casbin/jcasbin/detector/DefaultDetector.java @@ -0,0 +1,177 @@ +// Copyright 2025 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.casbin.jcasbin.detector; + +import org.casbin.jcasbin.rbac.DefaultRoleManager; +import org.casbin.jcasbin.rbac.RoleManager; + +import java.util.*; + +/** + * DefaultDetector is the default implementation of Detector interface. + * It uses depth-first search to detect cycles in RBAC role inheritance graph. + */ +public class DefaultDetector implements Detector { + + /** + * Checks whether the current status of the passed-in RoleManager contains logical errors (e.g., cycles in role inheritance). + * @param rm RoleManager instance + * @return If a cycle is found, return a description message in the form "Cycle detected: A -> B -> C -> A"; otherwise return null + */ + @Override + public String check(RoleManager rm) { + if (!(rm instanceof DefaultRoleManager)) { + throw new IllegalArgumentException("DefaultDetector only supports DefaultRoleManager"); + } + + DefaultRoleManager drm = (DefaultRoleManager) rm; + + // Build adjacency list from the role manager + // Using local data structures to avoid sharing references with RoleManager's internal state + Map> graph = buildGraph(drm); + + // Perform DFS to detect cycles + Set visited = new HashSet<>(); + Set recursionStack = new HashSet<>(); + Map parent = new HashMap<>(); + + for (String node : graph.keySet()) { + if (!visited.contains(node)) { + String cycle = dfs(node, graph, visited, recursionStack, parent); + if (cycle != null) { + return cycle; + } + } + } + + return null; + } + + /** + * Builds a directed graph (adjacency list) from the DefaultRoleManager. + * Each role points to the roles it inherits (its parent roles). + */ + private Map> buildGraph(DefaultRoleManager drm) { + Map> graph = new HashMap<>(); + + try { + // Use reflection to access the package-private allRoles field + java.lang.reflect.Field allRolesField = DefaultRoleManager.class.getDeclaredField("allRoles"); + allRolesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map allRoles = (Map) allRolesField.get(drm); + + // Iterate through all roles and get their parent roles + for (String roleName : allRoles.keySet()) { + List parentRoles = drm.getRoles(roleName); + graph.put(roleName, new ArrayList<>(parentRoles)); + } + } catch (NoSuchFieldException e) { + throw new RuntimeException("Failed to access 'allRoles' field in DefaultRoleManager via reflection. " + + "The field may have been renamed or removed.", e); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to access 'allRoles' field in DefaultRoleManager via reflection. " + + "Permission denied to access the field.", e); + } + + return graph; + } + + /** + * Performs depth-first search to detect cycles in the graph using an iterative approach. + * + * @param startNode Starting node for DFS + * @param graph The adjacency list representation of the role inheritance graph + * @param visited Set of all visited nodes + * @param recursionStack Set of nodes in current DFS path (used to detect back edges) + * @param parent Map to track parent of each node for cycle path reconstruction + * @return Cycle description if found, null otherwise + */ + private String dfs(String startNode, Map> graph, Set visited, + Set recursionStack, Map parent) { + // Use iterative DFS with explicit stack to avoid stack overflow on large graphs + Stack stack = new Stack<>(); + stack.push(new DFSState(startNode, 0)); + visited.add(startNode); + recursionStack.add(startNode); + + while (!stack.isEmpty()) { + DFSState state = stack.peek(); + String node = state.node; + List neighbors = graph.get(node); + + if (neighbors == null || state.index >= neighbors.size()) { + // All neighbors processed, backtrack + stack.pop(); + recursionStack.remove(node); + continue; + } + + String neighbor = neighbors.get(state.index); + state.index++; + + if (!visited.contains(neighbor)) { + parent.put(neighbor, node); + visited.add(neighbor); + recursionStack.add(neighbor); + stack.push(new DFSState(neighbor, 0)); + } else if (recursionStack.contains(neighbor)) { + // Cycle detected! Build the cycle path + parent.put(neighbor, node); + return buildCyclePath(neighbor, node, parent); + } + } + + return null; + } + + /** + * Helper class to maintain DFS state for iterative traversal. + */ + private static class DFSState { + String node; + int index; // Index of next neighbor to process + + DFSState(String node, int index) { + this.node = node; + this.index = index; + } + } + + /** + * Builds a human-readable cycle path description. + * + * @param cycleStart The node where the cycle was detected (the node being revisited) + * @param cycleEnd The current node that creates the back edge to cycleStart + * @param parent Map of parent relationships used to reconstruct the path + * @return Cycle description in the form "Cycle detected: A -> B -> C -> A" + */ + private String buildCyclePath(String cycleStart, String cycleEnd, Map parent) { + List path = new ArrayList<>(); + + // Build path from cycleEnd back to cycleStart + String current = cycleEnd; + while (current != null && !current.equals(cycleStart)) { + path.add(0, current); + current = parent.get(current); + } + + // Add cycleStart at the beginning and end to show the complete cycle + path.add(0, cycleStart); + path.add(cycleStart); + + return "Cycle detected: " + String.join(" -> ", path); + } +} diff --git a/src/test/java/org/casbin/jcasbin/main/DefaultDetectorTest.java b/src/test/java/org/casbin/jcasbin/main/DefaultDetectorTest.java new file mode 100644 index 00000000..c4d858eb --- /dev/null +++ b/src/test/java/org/casbin/jcasbin/main/DefaultDetectorTest.java @@ -0,0 +1,286 @@ +// Copyright 2025 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.casbin.jcasbin.main; + +import org.casbin.jcasbin.detector.DefaultDetector; +import org.casbin.jcasbin.detector.Detector; +import org.casbin.jcasbin.rbac.DefaultRoleManager; +import org.casbin.jcasbin.rbac.RoleManager; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Unit tests for DefaultDetector + */ +public class DefaultDetectorTest { + + @Test + public void testNoCycle() { + // Create a simple hierarchy without cycles + // u1 -> g1 -> g2 + // u2 -> g1 + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("u1", "g1"); + rm.addLink("u2", "g1"); + rm.addLink("g1", "g2"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNull("Expected no cycle to be detected", result); + } + + @Test + public void testSimpleCycle() { + // Create a simple cycle: A -> B -> C -> A + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("A", "B"); + rm.addLink("B", "C"); + rm.addLink("C", "A"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNotNull("Expected a cycle to be detected", result); + assertTrue("Result should contain 'Cycle detected'", result.contains("Cycle detected:")); + assertTrue("Result should contain role A", result.contains("A")); + assertTrue("Result should contain role B", result.contains("B")); + assertTrue("Result should contain role C", result.contains("C")); + } + + @Test + public void testSelfLoop() { + // Create a self-loop: A -> A + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("A", "A"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNotNull("Expected a cycle to be detected", result); + assertTrue("Result should contain 'Cycle detected'", result.contains("Cycle detected:")); + assertTrue("Result should contain role A", result.contains("A")); + } + + @Test + public void testTwoNodeCycle() { + // Create a two-node cycle: A -> B -> A + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("A", "B"); + rm.addLink("B", "A"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNotNull("Expected a cycle to be detected", result); + assertTrue("Result should contain 'Cycle detected'", result.contains("Cycle detected:")); + } + + @Test + public void testMultipleDisconnectedComponents() { + // Create multiple disconnected components, no cycles + // Component 1: u1 -> g1 -> g2 + // Component 2: u2 -> g3 -> g4 + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("u1", "g1"); + rm.addLink("g1", "g2"); + rm.addLink("u2", "g3"); + rm.addLink("g3", "g4"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNull("Expected no cycle to be detected", result); + } + + @Test + public void testCycleInOneComponent() { + // Create multiple components, cycle in one + // Component 1: u1 -> g1 -> g2 (no cycle) + // Component 2: A -> B -> C -> A (cycle) + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("u1", "g1"); + rm.addLink("g1", "g2"); + rm.addLink("A", "B"); + rm.addLink("B", "C"); + rm.addLink("C", "A"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNotNull("Expected a cycle to be detected", result); + assertTrue("Result should contain 'Cycle detected'", result.contains("Cycle detected:")); + } + + @Test + public void testComplexGraph() { + // Create a more complex graph with a cycle + // g3 g2 + // / \ / + // g1 u4 + // / \ + // u1 u2 + // And add a cycle: u4 -> g2 -> g3 -> u4 + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("u1", "g1"); + rm.addLink("u2", "g1"); + rm.addLink("g1", "g3"); + rm.addLink("u4", "g2"); + rm.addLink("u4", "g3"); + rm.addLink("g2", "g3"); + rm.addLink("g3", "u4"); // This creates a cycle + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNotNull("Expected a cycle to be detected", result); + assertTrue("Result should contain 'Cycle detected'", result.contains("Cycle detected:")); + } + + @Test + public void testEmptyRoleManager() { + // Test with an empty role manager + RoleManager rm = new DefaultRoleManager(10); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNull("Expected no cycle in empty graph", result); + } + + @Test + public void testSingleNode() { + // Test with a single node (no edges) + RoleManager rm = new DefaultRoleManager(10); + // Just calling getRoles creates the node but doesn't add any edges + rm.getRoles("A"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNull("Expected no cycle with single isolated node", result); + } + + @Test + public void testLargeGraph() { + // Test performance with a large graph (10000 roles) + // Create a chain: r0 -> r1 -> r2 -> ... -> r9999 + RoleManager rm = new DefaultRoleManager(10000); + + long startTime = System.currentTimeMillis(); + + for (int i = 0; i < 9999; i++) { + rm.addLink("r" + i, "r" + (i + 1)); + } + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + assertNull("Expected no cycle in large chain", result); + assertTrue("Detection should complete in reasonable time (< 5 seconds)", duration < 5000); + } + + @Test + public void testLargeGraphWithCycle() { + // Test with a large graph that has a cycle + // Create a chain: r0 -> r1 -> r2 -> ... -> r9998 -> r9999 -> r0 (cycle) + RoleManager rm = new DefaultRoleManager(10000); + + for (int i = 0; i < 9999; i++) { + rm.addLink("r" + i, "r" + (i + 1)); + } + rm.addLink("r9999", "r0"); // Create cycle + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + + assertNotNull("Expected a cycle to be detected in large graph", result); + assertTrue("Result should contain 'Cycle detected'", result.contains("Cycle detected:")); + } + + @Test(expected = IllegalArgumentException.class) + public void testUnsupportedRoleManager() { + // Test with a RoleManager that is not DefaultRoleManager + RoleManager rm = new RoleManager() { + @Override + public void clear() {} + + @Override + public void addLink(String name1, String name2, String... domain) {} + + @Override + public void deleteLink(String name1, String name2, String... domain) {} + + @Override + public boolean hasLink(String name1, String name2, String... domain) { + return false; + } + + @Override + public java.util.List getRoles(String name, String... domain) { + return null; + } + + @Override + public java.util.List getUsers(String name, String... domain) { + return null; + } + + @Override + public void printRoles() {} + }; + + Detector detector = new DefaultDetector(); + detector.check(rm); // Should throw IllegalArgumentException + } + + @Test + public void testCycleAfterClear() { + // Test that clearing a role manager removes the cycle + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("A", "B"); + rm.addLink("B", "C"); + rm.addLink("C", "A"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + assertNotNull("Expected a cycle before clear", result); + + rm.clear(); + result = detector.check(rm); + assertNull("Expected no cycle after clear", result); + } + + @Test + public void testCycleDetectionAfterDelete() { + // Test that deleting a link breaks the cycle + RoleManager rm = new DefaultRoleManager(10); + rm.addLink("A", "B"); + rm.addLink("B", "C"); + rm.addLink("C", "A"); + + Detector detector = new DefaultDetector(); + String result = detector.check(rm); + assertNotNull("Expected a cycle before delete", result); + + rm.deleteLink("C", "A"); + result = detector.check(rm); + assertNull("Expected no cycle after breaking the cycle", result); + } +}