Skip to content

Commit e1bc37a

Browse files
committed
Make role-hierarchy cycle logging crash-proof (fixes #37)
logRoleHierarchyCycle re-walks the role hierarchy to build a readable log message when the same role is reached twice while resolving a closure (which happens for a shared-ancestor diamond, not only a true cycle). That walk dereferenced roleDefinitionsMap.get(current) unguarded, so a role id with no entry in the map - a dangling parent reference - threw a NullPointerException that propagated out of getProjectRoleClosure and, from there, out of every capability check for whoever triggered it. Confirmed live: sharing a project at EDIT (role CanEdit, whose closure passes through ProjectViewer/IssueViewer) crashed every subsequent capability check for that collaborator, and the caller's swallow-to- empty-set on RPC failure turned that into a silent "Forbidden" for a successfully granted user. The reconstruction is diagnostic-only, so it now stops (still logging what it has) the moment it can't find a next role to walk to, instead of crashing.
1 parent 4af0ca8 commit e1bc37a

2 files changed

Lines changed: 89 additions & 2 deletions

File tree

src/main/java/edu/stanford/protege/webprotege/authorization/ProjectRoleDefinitionsManager.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,21 @@ private static void logRoleHierarchyCycle(RoleId currentRoleId, ArrayDeque<RoleI
153153
var cyclePath = new ArrayList<RoleId>();
154154
cyclePath.add(currentRoleId);
155155
var current = currentRoleId;
156-
while (toProcess.contains(current)) {
157-
current = roleDefinitionsMap.get(current).parentRoles().iterator().next();
156+
// Best-effort reconstruction of the cycle for this log message only - it must
157+
// never throw, since a crash here would turn a merely-logged, tolerated data
158+
// anomaly into a hard failure of whatever capability check triggered it. A
159+
// dangling parent reference (a role id with no entry in roleDefinitionsMap) or
160+
// a role with no parents left to follow both just end the reconstruction early
161+
// instead of crashing; the size-bounded guard is a backstop against any other
162+
// unanticipated non-termination.
163+
var guard = roleDefinitionsMap.size() + 1;
164+
while (guard-- > 0 && toProcess.contains(current)) {
165+
var role = roleDefinitionsMap.get(current);
166+
var parent = role == null ? null : role.parentRoles().stream().findFirst().orElse(null);
167+
if (parent == null) {
168+
break;
169+
}
170+
current = parent;
158171
cyclePath.add(current);
159172
}
160173
logger.warn("Cycle detected in role hierarchy: {}", String.join(" -> ",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package edu.stanford.protege.webprotege.authorization;
2+
3+
import edu.stanford.protege.webprotege.common.ProjectId;
4+
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.api.extension.ExtendWith;
6+
import org.mockito.Mock;
7+
import org.mockito.junit.jupiter.MockitoExtension;
8+
import org.mockito.junit.jupiter.MockitoSettings;
9+
import org.mockito.quality.Strictness;
10+
11+
import java.util.LinkedHashSet;
12+
import java.util.Optional;
13+
import java.util.Set;
14+
import java.util.stream.Collectors;
15+
16+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
17+
import static org.junit.jupiter.api.Assertions.assertEquals;
18+
import static org.mockito.Mockito.when;
19+
20+
/**
21+
* Guards against a regression of the crash logRoleHierarchyCycle used to cause: a role
22+
* whose parent chain is reached twice while resolving a closure (the code tolerates this
23+
* as a "cycle" - not necessarily a real one, a shared-ancestor diamond triggers the same
24+
* path) previously re-walked parentRoles() to build a log message, and that walk NPE'd
25+
* the instant it reached a role id with no entry in roleDefinitionsMap (a dangling parent
26+
* reference) - turning a merely-logged data anomaly into a hard failure of the closure
27+
* computation callers (and, in production, of every capability check for whoever held
28+
* that role).
29+
*/
30+
@ExtendWith(MockitoExtension.class)
31+
@MockitoSettings(strictness = Strictness.LENIENT)
32+
public class ProjectRoleDefinitionsManager_TestCase {
33+
34+
@Mock
35+
private ProjectRoleDefinitionsRepository repository;
36+
37+
private static RoleDefinition role(String id, String... parents) {
38+
return RoleDefinition.get(RoleId.valueOf(id),
39+
RoleType.PROJECT_ROLE,
40+
new LinkedHashSet<>(Set.of(parents)).stream()
41+
.map(RoleId::valueOf)
42+
.collect(Collectors.toCollection(LinkedHashSet::new)),
43+
Set.of(),
44+
id,
45+
id);
46+
}
47+
48+
@Test
49+
public void shouldNotThrowWhenADanglingParentReferenceIsReachedWhileLoggingATriggeredCycle() {
50+
// START has two children (A, B, D) that all share parent C, so C is enqueued
51+
// three times - the second dequeue of C is treated as a "cycle" (the code
52+
// cannot distinguish a real cycle from a shared-ancestor diamond) and triggers
53+
// the cycle-logging path. C's own parent, "Z", is deliberately never defined as
54+
// a role - reproducing the dangling reference the crash needs.
55+
var start = role("START", "A", "B", "D");
56+
var a = role("A", "C");
57+
var b = role("B", "C");
58+
var d = role("D", "C");
59+
var c = role("C", "Z");
60+
var projectId = ProjectId.valueOf("11111111-1111-1111-1111-111111111111");
61+
when(repository.getProjectRoleDefinitions(projectId))
62+
.thenReturn(Optional.of(ProjectRoleDefinitionsRecord.get(projectId, Set.of(start, a, b, d, c))));
63+
64+
var manager = new ProjectRoleDefinitionsManager(repository);
65+
66+
var closure = assertDoesNotThrow(
67+
() -> manager.getProjectRoleClosure(projectId, RoleId.valueOf("START")));
68+
69+
// "Z" was never resolvable to a RoleDefinition, so it is absent from the
70+
// closure - the outer algorithm already tolerates that gracefully; only the
71+
// logging reconstruction needed fixing.
72+
assertEquals(Set.of(start, a, b, d, c), closure);
73+
}
74+
}

0 commit comments

Comments
 (0)