-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Reduce database calls for evaluation of client roles #46224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| import java.security.PrivateKey; | ||
| import java.security.PublicKey; | ||
| import java.security.cert.X509Certificate; | ||
| import java.time.Duration; | ||
| import java.util.Arrays; | ||
| import java.util.Base64; | ||
| import java.util.Calendar; | ||
|
|
@@ -34,6 +35,7 @@ | |
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import java.util.UUID; | ||
| import java.util.concurrent.atomic.AtomicInteger; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
| import java.util.function.Function; | ||
| import java.util.regex.Pattern; | ||
|
|
@@ -90,7 +92,10 @@ | |
| import org.keycloak.transaction.RequestContextHelper; | ||
| import org.keycloak.utils.KeycloakSessionUtil; | ||
|
|
||
| import org.jboss.logging.Logger; | ||
| import com.github.benmanes.caffeine.cache.Cache; | ||
| import com.github.benmanes.caffeine.cache.Caffeine; | ||
| import io.micrometer.core.instrument.Metrics; | ||
| import io.micrometer.core.instrument.binder.cache.CaffeineStatsCounter; | ||
|
|
||
| import static org.keycloak.utils.StreamsUtil.closing; | ||
|
|
||
|
|
@@ -102,20 +107,33 @@ | |
| */ | ||
| public final class KeycloakModelUtils { | ||
|
|
||
| private static final Logger logger = Logger.getLogger(KeycloakModelUtils.class); | ||
| private static final Cache<RoleCacheKey, RoleCacheValue> ROLE_NAME_FROM_STRING_CACHE; | ||
|
|
||
| public static final String AUTH_TYPE_CLIENT_SECRET = "client-secret"; | ||
| public static final String AUTH_TYPE_CLIENT_SECRET_JWT = "client-secret-jwt"; | ||
|
|
||
| public static final String GROUP_PATH_SEPARATOR = "/"; | ||
| public static final String GROUP_PATH_ESCAPE = "~"; | ||
| private static final char CLIENT_ROLE_SEPARATOR = '.'; | ||
| private static final String CLIENT_ROLE_SEPARATOR_REGEX = "\\."; | ||
|
|
||
| public static final int MAX_CLIENT_LOOKUPS_DURING_ROLE_RESOLVE = 25; | ||
|
|
||
| public static final int DEFAULT_RSA_KEY_SIZE = 4096; | ||
| public static final int DEFAULT_CERTIFICATE_VALIDITY_YEARS = 3; | ||
|
|
||
| static { | ||
| var metrics = new CaffeineStatsCounter(Metrics.globalRegistry, "role.name.cache"); | ||
| var cache = Caffeine.newBuilder() | ||
| .maximumSize(10000) | ||
| // do not keep entries forever, as the combination of realm and client roles might lead to different matches eventually | ||
| .expireAfterWrite(Duration.ofHours(1)) | ||
| .recordStats(() -> metrics) | ||
| .<RoleCacheKey, RoleCacheValue>build(); | ||
| metrics.registerSizeMetric(cache); | ||
| ROLE_NAME_FROM_STRING_CACHE = cache; | ||
| } | ||
|
|
||
| private KeycloakModelUtils() { | ||
| } | ||
|
|
||
|
|
@@ -999,29 +1017,117 @@ public static RoleModel getRoleFromString(RealmModel realm, String roleName) { | |
| return null; | ||
| } | ||
|
|
||
| // Check client roles for all possible splits by dot | ||
| int counter = 0; | ||
| int scopeIndex = roleName.lastIndexOf(CLIENT_ROLE_SEPARATOR); | ||
| while (scopeIndex >= 0 && counter < MAX_CLIENT_LOOKUPS_DURING_ROLE_RESOLVE) { | ||
| counter++; | ||
| String appName = roleName.substring(0, scopeIndex); | ||
| ClientModel client = realm.getClientByClientId(appName); | ||
| if (client != null) { | ||
| String role = roleName.substring(scopeIndex + 1); | ||
| return client.getRole(role); | ||
| } | ||
| var roleNameParts = roleName.split(CLIENT_ROLE_SEPARATOR_REGEX); | ||
|
|
||
| scopeIndex = roleName.lastIndexOf(CLIENT_ROLE_SEPARATOR, scopeIndex - 1); | ||
| if (roleNameParts.length <= 1) { | ||
| // no separator present, check if it is a realm role | ||
| return realm.getRole(roleName); | ||
| } | ||
| if (counter >= MAX_CLIENT_LOOKUPS_DURING_ROLE_RESOLVE) { | ||
| logger.warnf("Not able to retrieve role model from the role name '%s'. Please use shorter role names with the limited amount of dots, roleName", roleName.length() > 100 ? roleName.substring(0, 100) + "..." : roleName); | ||
| return null; | ||
|
|
||
| if (roleNameParts.length == 2) { | ||
| return getRoleFromString(realm, roleNameParts[0], roleNameParts[1], roleName); | ||
| } | ||
|
|
||
| return getRoleFromStringCombinations(realm, roleNameParts, roleName); | ||
| } | ||
|
|
||
| private static RoleModel getRoleFromString(RealmModel realm, String clientId, String clientRoleName, String fullRoleName) { | ||
| // fast path, clientId.clientRole | ||
| // if client does not exist, check if it is a realm role | ||
| if (clientId.isEmpty()) { | ||
| // roleName starts with a dot(?) | ||
| return realm.getRole(fullRoleName); | ||
| } | ||
| var client = realm.getClientByClientId(clientId); | ||
| return client == null ? | ||
| realm.getRole(fullRoleName) : | ||
| client.getRole(clientRoleName); | ||
| } | ||
|
|
||
| private static RoleModel getRoleFromStringCombinations(RealmModel realm, String[] roleNameParts, String fullRoleName) { | ||
| var role = getRoleFromStringCombinationsFromCache(realm, fullRoleName); | ||
| if (role != null) { | ||
| return role.cachedRole(); | ||
| } | ||
| var startIdx = new AtomicInteger(); | ||
| var clientIdToTest = findNonEmptyClientId(roleNameParts, startIdx); | ||
| var clients = realm.searchClientByClientIdStream(clientIdToTest, null, null) | ||
| .collect(Collectors.toMap(ClientModel::getClientId, Function.identity())); | ||
|
Comment on lines
+1054
to
+1055
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had a look and it will do a full table scan on the clients table as it will run a A future enhancement might be that this method would allow to have percentage at the end, and then it could use an index range scan. Given that the results are now cached, this might be acceptable, still one would need to weight the different lookups. Sometimes client IDs are URLs (used in OIDC federation), so one could have a lot of clients starting with the same prefix before the dot, and then one would pull a lot of data from the database. I would like to be conservative here and would prefer the original code that loops over the dots as the response time would depend only on the number of dots in the role name, and not on the total number of clients in the database. |
||
|
|
||
| var idx = startIdx.incrementAndGet(); | ||
| var client = clients.get(clientIdToTest); | ||
| if (client != null) { | ||
| var roleName = mergeRoleName(roleNameParts, idx); | ||
| return getRoleAndCache(realm, client, roleName, fullRoleName); | ||
| } | ||
|
|
||
| for (; idx < roleNameParts.length - 1; ++idx) { | ||
| clientIdToTest += CLIENT_ROLE_SEPARATOR + roleNameParts[idx]; | ||
| client = clients.get(clientIdToTest); | ||
| if (client != null) { | ||
| var roleName = mergeRoleName(roleNameParts, idx + 1); | ||
| return getRoleAndCache(realm, client, roleName, fullRoleName); | ||
| } | ||
| } | ||
|
|
||
| // determine if roleName is a realm role | ||
| return getRoleAndCache(realm, fullRoleName); | ||
| } | ||
|
|
||
| private static CachedRole getRoleFromStringCombinationsFromCache(RealmModel realm, String fullRoleName) { | ||
| var cacheKey = new RoleCacheKey(realm.getId(), fullRoleName); | ||
| var cached = ROLE_NAME_FROM_STRING_CACHE.getIfPresent(cacheKey); | ||
| if (cached != null) { | ||
| if (cached.clientId() != null) { | ||
| var client = realm.getClientByClientId(cached.clientId()); | ||
| if (client != null) { | ||
| RoleModel role = client.getRole(cached.roleName()); | ||
| if (role != null) { | ||
| return new CachedRole(role); | ||
| } | ||
|
Comment on lines
+1085
to
+1087
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ahus1, this (and below) if check will introduce unnecessary invalidations.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The role might have been removed from the client, therefore I thought it would be good to invalidate it. |
||
| } | ||
| } else { | ||
| RoleModel role = realm.getRole(cached.roleName()); | ||
| if (role != null) { | ||
| return new CachedRole(role); | ||
| } | ||
| } | ||
| // client or role not found, invalidate cache | ||
| ROLE_NAME_FROM_STRING_CACHE.invalidate(cacheKey); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private static RoleModel getRoleAndCache(RealmModel realm, ClientModel client, String roleName, String fullRoleName) { | ||
| ROLE_NAME_FROM_STRING_CACHE.put(new RoleCacheKey(realm.getId(), fullRoleName), new RoleCacheValue(client.getClientId(), roleName)); | ||
| return client.getRole(roleName); | ||
| } | ||
|
|
||
| private static RoleModel getRoleAndCache(RealmModel realm, String roleName) { | ||
| ROLE_NAME_FROM_STRING_CACHE.put(new RoleCacheKey(realm.getId(), roleName), new RoleCacheValue(null, roleName)); | ||
| return realm.getRole(roleName); | ||
| } | ||
|
|
||
| private static String mergeRoleName(String[] parts, int startIndex) { | ||
| return Arrays.stream(parts) | ||
| .skip(startIndex) | ||
| .collect(Collectors.joining(String.valueOf(CLIENT_ROLE_SEPARATOR))); | ||
| } | ||
|
|
||
| private static String findNonEmptyClientId(String[] parts, AtomicInteger idx) { | ||
| // just in case the client Id starts with a dot | ||
| var builder = new StringBuilder(); | ||
| for (; idx.get() < parts.length; idx.incrementAndGet()) { | ||
| String part = parts[idx.get()]; | ||
| builder.append(part); | ||
| if (!part.isEmpty()) { | ||
| break; | ||
| } | ||
| builder.append(CLIENT_ROLE_SEPARATOR); | ||
| } | ||
| return builder.toString(); | ||
| } | ||
|
|
||
| // Used for hardcoded role mappers | ||
| public static String[] parseRole(String role) { | ||
| int scopeIndex = role.lastIndexOf(CLIENT_ROLE_SEPARATOR); | ||
|
|
@@ -1305,4 +1411,19 @@ public static List<String> getAcceptedClientScopeProtocols(ClientModel client) { | |
| } | ||
| return acceptedClientProtocols; | ||
| } | ||
|
|
||
| private record RoleCacheValue(String clientId, String roleName) { | ||
| private RoleCacheValue { | ||
| Objects.requireNonNull(roleName); | ||
| } | ||
| } | ||
|
|
||
| private record RoleCacheKey(String realmId, String roleNameString) { | ||
| private RoleCacheKey { | ||
| Objects.requireNonNull(realmId); | ||
| Objects.requireNonNull(roleNameString); | ||
| } | ||
| } | ||
|
|
||
| private record CachedRole(RoleModel cachedRole) {} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we go without the |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please try to move some of this to the
DefaultAlternativeLookupProvider?You can get hold of the session using
KeycloakSessionUtil.getKeycloakSession()... this would make sure that there is no leaking of information between session factories.