Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,39 +17,61 @@
package org.keycloak.models.cache.infinispan;

import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

import org.keycloak.models.KeycloakSession;

import static org.keycloak.authorization.fgap.AdminPermissionsSchema.runWithoutAuthorization;

/**
* Default implementation of {@link DefaultLazyLoader} that only fetches data once. This implementation is thread-safe
* as cached data is used in instanced of {@link org.keycloak.models.cache.infinispan.entities.CachedRealm} which are shared
* between multiple threads within a Keycloak instance.
* Default implementation of {@link LazyLoader} that lazily loads and caches data. Data loaded from a source rejected by
* the configured cacheability predicate is returned without being cached, so a subsequent invocation attempts to load
* the data again. This implementation is thread-safe as cached data is used in instances of
* {@link org.keycloak.models.cache.infinispan.entities.CachedRealm} which are shared between multiple threads within a
* Keycloak instance.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class DefaultLazyLoader<S, D> implements LazyLoader<S, D> {

private final Function<S, D> loader;
private final Supplier<D> fallback;
private final Predicate<S> cacheable;
private volatile D data;

public DefaultLazyLoader(Function<S, D> loader, Supplier<D> fallback) {
this(loader, fallback, source -> true);
}

/**
* Creates a loader that caches loaded data only when {@code cacheable} accepts its source.
*
* @param loader function used to load data from a non-null source
* @param fallback supplier used when the source is null
* @param cacheable predicate evaluated against a non-null source after its data has been loaded. If it returns
* {@code false}, the loaded data is returned without being cached and a subsequent invocation attempts to load
* again. When the source is null, the predicate is not evaluated and the fallback result is stored instead.
*/
public DefaultLazyLoader(Function<S, D> loader, Supplier<D> fallback, Predicate<S> cacheable) {
this.loader = loader;
this.fallback = fallback;
this.cacheable = cacheable;
}

@Override
public D get(KeycloakSession session, Supplier<S> sourceSupplier) {
if (data == null) {
synchronized (this) {
if (data == null) {
runWithoutAuthorization(session, () -> {
return runWithoutAuthorization(session, () -> {
// make sure caching does not include partial results when FGAP is enabled
S source = sourceSupplier.get();
data = source == null ? fallback.get() : loader.apply(source);
D loaded = source == null ? fallback.get() : loader.apply(source);
if (source == null || cacheable.test(source)) {
data = loaded;
}
return loaded;
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.infinispan.DefaultLazyLoader;
import org.keycloak.models.cache.infinispan.LazyLoader;
import org.keycloak.models.utils.StorageUnavailableUserModelDelegate;

/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
Expand Down Expand Up @@ -71,11 +72,15 @@ public CachedUser(long revision, RealmModel realm, UserModel user, int notBefore
this.eagerLoadedAttributes.putSingle(UserModel.FIRST_NAME,user.getFirstName());
this.eagerLoadedAttributes.putSingle(UserModel.LAST_NAME,user.getLastName());
this.eagerLoadedAttributes.putSingle(UserModel.EMAIL,user.getEmail());
this.lazyLoadedAttributes = new DefaultLazyLoader<>(userModel -> new MultivaluedHashMap<>(userModel.getAttributes()), MultivaluedHashMap::new);
this.requiredActions = new DefaultLazyLoader<>(userModel -> userModel.getRequiredActionsStream().collect(Collectors.toSet()), Collections::emptySet);
this.roleMappings = new DefaultLazyLoader<>(userModel -> userModel.getRoleMappingsStream().map(RoleModel::getId).collect(Collectors.toSet()), Collections::emptySet);
this.groups = new DefaultLazyLoader<>(userModel -> userModel.getGroupsStream().map(GroupModel::getId).collect(Collectors.toCollection(LinkedHashSet::new)), LinkedHashSet::new);
this.storedCredentials = new DefaultLazyLoader<>(userModel -> userModel.credentialManager().getStoredCredentialsStream().collect(Collectors.toCollection(LinkedList::new)), LinkedList::new);
this.lazyLoadedAttributes = new DefaultLazyLoader<>(userModel -> new MultivaluedHashMap<>(userModel.getAttributes()), MultivaluedHashMap::new, CachedUser::isStorageAvailable);
this.requiredActions = new DefaultLazyLoader<>(userModel -> userModel.getRequiredActionsStream().collect(Collectors.toSet()), Collections::emptySet, CachedUser::isStorageAvailable);
this.roleMappings = new DefaultLazyLoader<>(userModel -> userModel.getRoleMappingsStream().map(RoleModel::getId).collect(Collectors.toSet()), Collections::emptySet, CachedUser::isStorageAvailable);
this.groups = new DefaultLazyLoader<>(userModel -> userModel.getGroupsStream().map(GroupModel::getId).collect(Collectors.toCollection(LinkedHashSet::new)), LinkedHashSet::new, CachedUser::isStorageAvailable);
this.storedCredentials = new DefaultLazyLoader<>(userModel -> userModel.credentialManager().getStoredCredentialsStream().collect(Collectors.toCollection(LinkedList::new)), LinkedList::new, CachedUser::isStorageAvailable);
}

private static boolean isStorageAvailable(UserModel userModel) {
return !(userModel instanceof StorageUnavailableUserModelDelegate);
}

public String getRealm() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.models.cache.infinispan;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class DefaultLazyLoaderTest {

@Test
public void shouldRetryWhenSourceIsNotCacheable() {
AtomicInteger loads = new AtomicInteger();
AtomicReference<String> source = new AtomicReference<>("unavailable");
DefaultLazyLoader<String, String> loader = new DefaultLazyLoader<>(
value -> {
loads.incrementAndGet();
return value;
},
() -> "fallback",
value -> !"unavailable".equals(value));

assertEquals("unavailable", loader.get(null, source::get));
source.set("available");
assertEquals("available", loader.get(null, source::get));
source.set("changed");
assertEquals("available", loader.get(null, source::get));
assertEquals(2, loads.get());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public class FailableHardcodedStorageProvider implements UserStorageProvider, Us
public static String email = "billb@nowhere.com";
public static String first = "Bill";
public static String last = "Burke";
public static String groupName;
public static MultivaluedHashMap<String, String> attributes = new MultivaluedHashMap<>();

public static boolean fail;
Expand Down Expand Up @@ -121,8 +122,13 @@ public boolean isValid(RealmModel realm, UserModel user, CredentialInput credent
}

private static class Delegate extends UserModelDelegate {
public Delegate(UserModel delegate) {
private final KeycloakSession session;
private final RealmModel realm;

public Delegate(UserModel delegate, KeycloakSession session, RealmModel realm) {
super(delegate);
this.session = session;
this.realm = realm;
}

@Override
Expand Down Expand Up @@ -166,6 +172,16 @@ public void setEmail(String em) {
super.setEmail(em);
email = em;
}

@Override
public Stream<GroupModel> getGroupsStream() {
if (groupName == null) {
return super.getGroupsStream();
}

GroupModel group = session.groups().getGroupByName(realm, null, groupName);
return group == null ? Stream.empty() : Stream.of(group);
}
}

@Override
Expand All @@ -174,7 +190,7 @@ public UserModel validate(RealmModel realm, UserModel user) {
if (failOnValidation) {
throw new RuntimeException("Forcing validation failure");
}
return new Delegate(user);
return new Delegate(user, session, realm);
}

@Override
Expand All @@ -191,7 +207,7 @@ public UserModel getUserByUsername(RealmModel realm, String uname) {
if (local != null && !model.getId().equals(local.getFederationLink())) {
throw new RuntimeException("local storage has wrong federation link");
}
if (local != null) return new Delegate(local);
if (local != null) return new Delegate(local, session, realm);
local = UserStoragePrivateUtil.userLocalStorage(session).addUser(realm, uname);
local.setEnabled(true);
local.setFirstName(first);
Expand All @@ -203,7 +219,7 @@ public UserModel getUserByUsername(RealmModel realm, String uname) {
if (values == null) continue;
local.setAttribute(entry.getKey(), values);
}
return new Delegate(local);
return new Delegate(local, session, realm);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,54 @@ public void testDisableUsersWhenFailing() {
}
}

@Test
public void testUnavailableStorageDoesNotCacheEmptyGroups() {
String groupName = "external-group";

try {
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName(AuthRealm.TEST);
UserStorageUtil.userCache(session).evict(realm);
if (session.groups().getGroupByName(realm, null, groupName) == null) {
realm.createGroup(groupName);
}
FailableHardcodedStorageProvider.groupName = groupName;

session.users().getUserByUsername(realm, FailableHardcodedStorageProvider.username);
});
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName(AuthRealm.TEST);
UserModel user = session.users().getUserByUsername(realm, FailableHardcodedStorageProvider.username);
Assertions.assertTrue(user instanceof CachedUserModel);
});

toggleForceFailOnValidation(true);
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName(AuthRealm.TEST);
UserModel user = session.users().getUserByUsername(realm, FailableHardcodedStorageProvider.username);
Assertions.assertEquals(0, user.getGroupsStream().count());
});

toggleForceFailOnValidation(false);
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName(AuthRealm.TEST);
UserModel user = session.users().getUserByUsername(realm, FailableHardcodedStorageProvider.username);
Assertions.assertEquals(groupName, user.getGroupsStream().findFirst().orElseThrow().getName());
});
} finally {
toggleForceFailOnValidation(false);
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName(AuthRealm.TEST);
FailableHardcodedStorageProvider.groupName = null;
UserStorageUtil.userCache(session).evict(realm);
var group = session.groups().getGroupByName(realm, null, groupName);
if (group != null) {
realm.removeGroup(group);
}
});
}
}

private void enableCache() {
ComponentRepresentation component = managedRealm.admin().components().query().stream()
.filter(c -> c.getProviderId().equals(FailableHardcodedStorageProviderFactory.PROVIDER_ID))
Expand Down