Skip to content

Filter stale role IDs in Infinispan client adapters - #51616

Open
PRAHLAD09-dev wants to merge 1 commit into
keycloak:mainfrom
PRAHLAD09-dev:fix/GH-51589-cache-scope-mapping-npe
Open

Filter stale role IDs in Infinispan client adapters#51616
PRAHLAD09-dev wants to merge 1 commit into
keycloak:mainfrom
PRAHLAD09-dev:fix/GH-51589-cache-scope-mapping-npe

Conversation

@PRAHLAD09-dev

Copy link
Copy Markdown

ClientAdapter and ClientScopeAdapter in the Infinispan cache currently resolve cached role IDs without handling the case where a role can no longer be resolved.

This can result in null RoleModel entries being returned from getScopeMappingsStream(), which can subsequently cause failures when the mappings are processed by composite-role expansion.

The corresponding JPA adapters already filter unresolved roles using Objects::nonNull. This PR applies the same null-filtering behavior to the Infinispan implementations.

Changes

  • Filter unresolved role mappings in ClientAdapter#getScopeMappingsStream().
  • Filter unresolved role mappings in ClientScopeAdapter#getScopeMappingsStream().
  • Keep the change limited to the affected Infinispan adapters.

Verification

  • git diff --check passes.
  • mvnw.cmd -pl model\infinispan -am -DskipTests compile passes.

Fixes #51589

Signed-off-by: Prahlad Bhakat <prahladbhakat05@gmail.com>
Copilot AI balanced review requested due to automatic review settings August 11, 2026 11:40
@PRAHLAD09-dev
PRAHLAD09-dev requested review from a team as code owners August 11, 2026 11:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Filters unresolved cached role IDs to prevent null role mappings and composite-role expansion failures.

Changes:

  • Adds null filtering to client and client-scope Infinispan adapters.
  • Aligns behavior with JPA adapters.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
ClientScopeAdapter.java Filters unresolved client-scope role mappings.
ClientAdapter.java Filters unresolved client role mappings.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

return cached.getScope().stream()
.map(id -> cacheSession.getRoleById(cachedRealm, id));
.map(id -> cacheSession.getRoleById(cachedRealm, id))
.filter(Objects::nonNull);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I checked the existing model/cache test infrastructure and confirmed that the existing tests cover the normal cache invalidation path.

For this specific regression, reproducing an unknown role ID in CachedClient deterministically requires bypassing the normal cache invalidation path. The available test infrastructure does not provide a supported API for injecting such a stale cached role ID, and Mockito is not an existing dependency of this module.

I therefore kept the fix limited to the null handling, matching the existing JPA implementation with Objects::nonNull, rather than introducing a new mocking dependency or relying on internal cache-state manipulation.

return cached.getScope().stream()
.map(id -> cacheSession.getRoleById(cachedRealm, id));
.map(id -> cacheSession.getRoleById(cachedRealm, id))
.filter(Objects::nonNull);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same applies to CachedClientScope. The existing test infrastructure covers normal cache invalidation, but does not provide a supported way to inject an arbitrary stale role ID into the cached scope mappings.

Adding a mocking dependency or manipulating internal cache state solely for this race-condition scenario would add unnecessary test infrastructure to the module. The production change mirrors the existing JPA implementation by filtering unresolved RoleModel instances with Objects::nonNull.

@ssilvert

ssilvert commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

This is a good, minimal fix and it matches the JPA adapters exactly — I (Claude code) traced the NPE through RealmCacheSession.getRoleById() returning null for an unresolvable role, into RoleUtils.expandCompositeRoles()'s ArrayDeque, which confirms the root cause described in #51589.

One thing I'd like to see before merge: a regression test. model/infinispan doesn't have existing unit tests for ClientAdapter/ClientScopeAdapter, so this bug shipped without anything catching it, and a compile-only check won't stop it from regressing again. The good news is ClientAdapter and ClientScopeAdapter are easy to instantiate directly with mocked collaborators — no need for a full DB/cache integration test. Something like:

package org.keycloak.models.cache.infinispan;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.util.List;
import java.util.Set;

import org.junit.jupiter.api.Test;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.cache.infinispan.entities.CachedClient;

class ClientAdapterTest {

    @Test
    void getScopeMappingsStreamFiltersUnresolvableRoleIds() {
        RealmModel realm = mock(RealmModel.class);
        RealmCacheSession cacheSession = mock(RealmCacheSession.class);
        CachedClient cached = mock(CachedClient.class);

        RoleModel liveRole = mock(RoleModel.class);
        when(cached.getScope()).thenReturn(Set.of("stale-role-id", "live-role-id"));
        // simulates a role deleted on another node whose cache invalidation
        // hasn't reached this one yet
        when(cacheSession.getRoleById(eq(realm), eq("stale-role-id"))).thenReturn(null);
        when(cacheSession.getRoleById(eq(realm), eq("live-role-id"))).thenReturn(liveRole);

        ClientAdapter adapter = new ClientAdapter(realm, cached, cacheSession);

        List<RoleModel> result = adapter.getScopeMappingsStream().toList();

        assertEquals(List.of(liveRole), result,
                "a scope mapping pointing at an unresolvable role must be silently dropped, not surfaced as null");
    }
}

The equivalent test for ClientScopeAdapter follows the same shape — swap CachedClient for CachedClientScope and the constructor args (ClientScopeAdapter(RealmModel, CachedClientScope, RealmCacheSession) — check the actual signature).

Two things worth double-checking when you write it:

  • ClientAdapter.isUpdated() short-circuits to false as long as invalidated is false and updated == null (both true by default on a fresh instance), so you shouldn't need to stub cacheSession.getClientDelegate() — but verify that against the constructor you're using in case defaults differ.
  • Assert on the filtered set only — don't assert cacheSession.getRoleById() was called exactly twice, since that couples the test to iteration order/count rather than the behavior you actually care about (nulls don't leak into the stream).

@ssilvert ssilvert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my other comment.

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.

NPE in RoleUtils.expandCompositeRoles when a cached client scope references a deleted role

3 participants