Skip to content

fix(sso): do not cache a failed Entra ID parent group lookup - #3223

Open
marevol wants to merge 1 commit into
masterfrom
entraid-group-cache-correctness
Open

fix(sso): do not cache a failed Entra ID parent group lookup#3223
marevol wants to merge 1 commit into
masterfrom
entraid-group-cache-correctness

Conversation

@marevol

@marevol marevol commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

A transient Graph failure is cached as "no parent groups" for 10 minutes

The groupCache loader handled its own failures and returned an empty Pair:

} catch (final IOException e) {
    logger.warn("Failed to access groups/roles in Entra ID for id: {}", id, e);
}
final Pair<String[], String[]> result = new Pair<>(groupList..., roleList...);   // empty
return result;                                                                   // ...and cached

Guava has no way to tell that apart from a real empty answer, so it stores it for
groupCacheExpiry (10 minutes by default). Throttling (429), a 5xx, or a brief network blip
therefore pins "this group has no parents" for every user who logs in during that window. They
lose their parent-group permissions and see it as "some documents are missing"; the only trace is
a WARN line. The error-response branch had the same shape.

An unchecked exception escapes the handler

} catch (final ExecutionException e) {

Guava wraps a checked exception from the loader in ExecutionException, but an unchecked
one in UncheckedExecutionException, which is not a subtype. The Graph JSON parser
(OpenSearchCurl's) throws CurlException, a RuntimeException, whenever an error body is not
JSON — for example an HTML error page from a proxy — so that case escaped this catch entirely.

Fix

  • getMemberGroupIds(user, id) — the Graph call, extracted. It throws IOException for
    transport failures and for error responses, and returns an empty array for
    Request_ResourceNotFound, which is a real answer and should stay cached.
  • loadParentGroup(user, id, depth) — the walk, with failures propagating. A loader that
    throws leaves nothing in the cache, so the next login retries.
  • The catch is now ExecutionException | UncheckedExecutionException.

Behaviour on failure is otherwise unchanged: a WARN plus an empty result for that one caller.

A note on what this PR does not change

While testing this I found that the nested-group recursion never runs on the success path:

processGroup(user, groupList, roleList, value);          // unconditionally does groupList.add(id)
if (!groupList.contains(value) && !roleList.contains(value)) {   // therefore always false
    ... getParentGroup(user, value, depth + 1) ...
}

processGroup() adds the id it was asked about before returning, so the guard is false for every
group it resolved. The recursion only fires when processGroup threw an IOException — and
maxGroupDepth has no effect on the normal path.

That is consistent with Graph's getMemberGroups already being transitive (one call returns every
ancestor), so it may well be correct as-is. I have not changed it here;
test_getParentGroup_doesNotRecurseOnceTheParentWasResolved pins the current behaviour so any
change is deliberate.

Tests

EntraIdAuthenticator's unit test class, 5 new tests, driven through a subclass whose Graph
access is scripted:

  • test_getParentGroup_cachesASuccessfulLookup
  • test_getParentGroup_doesNotCacheAFailedLookup — fails, is not cached, and the next call
    succeeds
  • test_getParentGroup_cachesAGenuineEmptyResult
  • test_getParentGroup_survivesAnUncheckedFailureFromTheLoader
  • test_getParentGroup_doesNotRecurseOnceTheParentWasResolved — the characterisation above

Falsification: restoring the swallow-and-cache behaviour fails the second test; narrowing the
catch back to ExecutionException errors the fourth.

Tests run: 136, Failures: 0, Errors: 0, Skipped: 0

(the whole org.codelibs.fess.sso package)

The groupCache loader caught IOException itself and returned an empty Pair, so
Guava stored that empty result. A throttled or briefly unreachable Microsoft
Graph therefore pinned "this group has no parents" for the whole cache TTL
(10 minutes by default), and every user who logged in during that window
silently lost their parent group permissions. The only trace was a warning.

Split the Graph call out into getMemberGroupIds() and let failures propagate:
a loader that throws leaves nothing in the cache, so the next login retries.
Graph's Request_ResourceNotFound is mapped to an empty array instead, because
"this group does not exist" is a real answer and should stay cached.

Also widen the catch around groupCache.get() to UncheckedExecutionException.
Guava wraps an unchecked exception from the loader in that type, which is not an
ExecutionException, so it escaped the handler entirely -- and the Graph JSON
parser throws CurlException, a RuntimeException, whenever an error body is not
JSON.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant