Thanks to visit codestin.com
Credit goes to Github.com

Skip to content
Closed
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
8 changes: 8 additions & 0 deletions server-spi-private/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@
<artifactId>guava</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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;
}

Comment on lines +125 to +136
Copy link
Member

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.

private KeycloakModelUtils() {
}

Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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 LIKE operator with % at the beginning and the end.

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
Copy link
Member Author

Choose a reason for hiding this comment

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

@ahus1, this (and below) if check will introduce unnecessary invalidations.
The search for the client id will return the same client, and it will invoke client.getRole() again.

Copy link
Member

Choose a reason for hiding this comment

The 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.
It is Friday and getting late, maybe I'm getting tired. Happy to discuss it more on Monday.

}
} 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);
Expand Down Expand Up @@ -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) {}
Copy link
Member

Choose a reason for hiding this comment

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

Could we go without the CachedRole record as a simplification?

}
Loading
Loading