Skip to content

Commit

Permalink
Improve performance for role mapping with DNs (#92074) (#92175)
Browse files Browse the repository at this point in the history
* Improve performance for role mapping with DNs (#92074)

DNs in a role mapping are parsed each time a matching is performed
against a user group. If there are large number of role mappings and
large number of user groups, this process can be quite slow since DN
parsing is non-trivial.

This PR improves the performance by introducing a cache for DN parsing
so that an unique DN from all role mappings is only parsed once per user
(regardless of how many groups the user has). The cache stores the
normalized string format of the DN instead of the DN object itself since
DN is rather memory expensive. The cache's lifecycle is tied to the
UserData model which goes out of scope once role mapping process is
completed. Hence it is short-lived and does not need to externally
managed.

Thanks to @tvernum for the original idea.

Co-authored-by: Tim Vernum <tim.vernum@elastic.co>

* fix backport omission

Co-authored-by: Tim Vernum <tim.vernum@elastic.co>
  • Loading branch information
ywangd and tvernum committed Dec 9, 2022
1 parent ff7d6cf commit 88e5311
Show file tree
Hide file tree
Showing 4 changed files with 281 additions and 34 deletions.
5 changes: 5 additions & 0 deletions docs/changelog/92074.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 92074
summary: Improve performance for role mapping with DNs
area: Authentication
type: bug
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.ParameterizedMessage;
import org.apache.lucene.util.automaton.CharacterRunAutomaton;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.core.Nullable;
Expand All @@ -21,8 +20,10 @@
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.FieldExpression;
import org.elasticsearch.xpack.core.security.authz.permission.Role;

import java.lang.ref.SoftReference;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -77,16 +78,19 @@ public UserData(String username, @Nullable String dn, Collection<String> groups,
*/
public ExpressionModel asModel() {
final ExpressionModel model = new ExpressionModel();
final DistinguishedNameNormalizer dnNormalizer = getDnNormalizer();
model.defineField("username", username);
if (dn != null) {
// null dn fields get the default NULL_PREDICATE
model.defineField("dn", dn, new DistinguishedNamePredicate(dn));
model.defineField("dn", dn, new DistinguishedNamePredicate(dn, dnNormalizer));
}
model.defineField(
"groups",
groups,
groups.stream()
.filter(group -> group != null).<Predicate<FieldExpression.FieldValue>>map(DistinguishedNamePredicate::new)
.filter(group -> group != null).<Predicate<FieldExpression.FieldValue>>map(
g -> new DistinguishedNamePredicate(g, dnNormalizer)
)
.reduce(Predicate::or)
.orElse(fieldValue -> false)
);
Expand Down Expand Up @@ -149,6 +153,67 @@ public Map<String, Object> getMetadata() {
public RealmConfig getRealm() {
return realm;
}

// Package private for testing
DistinguishedNameNormalizer getDnNormalizer() {
return new DistinguishedNameNormalizer();
}
}

/**
* This class parse the given string into a DN and return its normalized format.
* If the input string is not a valid DN, {@code null} is returned.
* The DN parsing and normalization are cached internally so that the same
* input string will only be processed once (as long as the cache entry is not GC'd).
* The cache works regardless of whether the input string is a valid DN.
*
* The cache uses {@link SoftReference} for its values so that they free for GC.
* This is to prevent potential memory pressure when there are many concurrent role
* mapping processes coupled with large number of groups and role mappings, which
* in theory is unbounded.
*/
class DistinguishedNameNormalizer {
private static final Logger LOGGER = LogManager.getLogger(DistinguishedNameNormalizer.class);
private static final SoftReference<String> NULL_REF = new SoftReference<>(null);
private final Map<String, SoftReference<String>> cache = new HashMap<>();

/**
* Parse the input string to a DN and returns its normalized form.
* @param str String that may represent a DN
* @return The normalized DN form of the input string or {@code null} if input string is not a DN
*/
public String normalize(String str) {
final SoftReference<String> normalizedDnRef = cache.get(str);
if (normalizedDnRef == NULL_REF) {
return null;
}
if (normalizedDnRef != null) {
final String normalizedDn = normalizedDnRef.get();
if (normalizedDn != null) {
return normalizedDn;
}
}
final String normalizedDn = doNormalize(str);
if (normalizedDn == null) {
cache.put(str, NULL_REF);
} else {
cache.put(str, new SoftReference<>(normalizedDn));
}
return normalizedDn;
}

String doNormalize(String str) {
final DN dn;
try {
dn = new DN(str);
} catch (LDAPException | LDAPSDKUsageException e) {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace(() -> "failed to parse [" + str + "] as a DN", e);
}
return null;
}
return dn.toNormalizedString();
}
}

/**
Expand All @@ -169,26 +234,16 @@ public RealmConfig getRealm() {
*
*/
class DistinguishedNamePredicate implements Predicate<FieldExpression.FieldValue> {
private static final Logger LOGGER = LogManager.getLogger(DistinguishedNamePredicate.class);

private final String string;
private final DN dn;
private final DistinguishedNameNormalizer dnNormalizer;
private final String normalizedDn;

public DistinguishedNamePredicate(String string) {
public DistinguishedNamePredicate(String string, DistinguishedNameNormalizer dnNormalizer) {
assert string != null : "DN string should not be null. Use the dedicated NULL_PREDICATE for every user null field.";
this.string = string;
this.dn = parseDn(string);
}

private static DN parseDn(String string) {
try {
return new DN(string);
} catch (LDAPException | LDAPSDKUsageException e) {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace(new ParameterizedMessage("failed to parse [{}] as a DN", string), e);
}
return null;
}
this.dnNormalizer = dnNormalizer;
this.normalizedDn = dnNormalizer.normalize(string);
}

@Override
Expand All @@ -203,13 +258,13 @@ public boolean test(FieldExpression.FieldValue fieldValue) {
if (automaton.run(string)) {
return true;
}
if (dn != null && automaton.run(dn.toNormalizedString())) {
if (normalizedDn != null && automaton.run(normalizedDn)) {
return true;
}
if (automaton.run(string.toLowerCase(Locale.ROOT)) || automaton.run(string.toUpperCase(Locale.ROOT))) {
return true;
}
if (dn == null) {
if (normalizedDn == null) {
return false;
}

Expand All @@ -221,15 +276,11 @@ public boolean test(FieldExpression.FieldValue fieldValue) {
String pattern = (String) fieldValue.getValue();

// If the pattern is "*,dc=example,dc=com" then the rule is actually trying to express a DN sub-tree match.
// We can use dn.isDescendantOf for that
if (pattern.startsWith("*,")) {
final String suffix = pattern.substring(2);
// if the suffix has a wildcard, then it's not a pure sub-tree match
if (suffix.indexOf('*') == -1) {
final DN dnSuffix = parseDn(suffix);
if (dnSuffix != null && dn.isDescendantOf(dnSuffix, false)) {
return true;
}
return isDescendantOf(dnNormalizer.normalize(suffix));
}
}

Expand All @@ -240,17 +291,24 @@ public boolean test(FieldExpression.FieldValue fieldValue) {
if (testString.equalsIgnoreCase(string)) {
return true;
}
if (dn == null) {
if (normalizedDn == null) {
return false;
}

final DN testDn = parseDn(testString);
if (testDn != null) {
return dn.equals(testDn);
final String testNormalizedDn = dnNormalizer.normalize(testString);
if (testNormalizedDn != null) {
return normalizedDn.equals(testNormalizedDn);
}
return testString.equalsIgnoreCase(dn.toNormalizedString());
return testString.equalsIgnoreCase(normalizedDn);
}
return false;
}

private boolean isDescendantOf(String normalizedDnSuffix) {
if (normalizedDnSuffix == null) {
return false;
}
return normalizedDn.endsWith("," + normalizedDnSuffix) || (normalizedDnSuffix.isEmpty() && false == normalizedDn.isEmpty());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.core.security.authc.support;

import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.ExpressionModel;
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.FieldExpression.FieldValue;
import org.junit.Before;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;

import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class DistinguishedNameNormalizerTests extends ESTestCase {

private UserRoleMapper.DistinguishedNameNormalizer dnNormalizer;

@Before
public void init() {
dnNormalizer = getDnNormalizer();
}

public void testDnNormalizingIsCached() {
// Parse same DN multiple times, only 1st time DN parsing is performed, 2nd time reads from the cache
Mockito.clearInvocations(dnNormalizer);
final String dn = randomDn();
parseDnMultipleTimes(dn);
verify(dnNormalizer, times(1)).doNormalize(dn);

// The cache is keyed by the literal string form.
// Therefore if the literal string changes, it needs to be parsed again even though it is still the same DN
Mockito.clearInvocations(dnNormalizer);
final String mutatedDn = mutateDn(dn);
parseDnMultipleTimes(mutatedDn);
verify(dnNormalizer, times(1)).doNormalize(mutatedDn);

// Invalid DNs should also be cached
Mockito.clearInvocations(dnNormalizer);
final String invalidDn = randomFrom(
"",
randomAlphaOfLengthBetween(1, 8),
randomAlphaOfLengthBetween(1, 8) + "*",
randomAlphaOfLengthBetween(1, 8) + "=",
"=" + randomAlphaOfLengthBetween(1, 8)
);
parseDnMultipleTimes(invalidDn);
verify(dnNormalizer, times(1)).doNormalize(invalidDn);
}

public void testDnNormalizingIsCachedForDnPredicate() {
final String dn = randomDn();
final Predicate<FieldValue> predicate = new UserRoleMapper.DistinguishedNamePredicate(dn, dnNormalizer);
verify(dnNormalizer, times(1)).doNormalize(dn);

// Same DN, it's cached
runPredicateMultipleTimes(predicate, dn);
verify(dnNormalizer, times(1)).doNormalize(dn);

// Predicate short-circuits for case differences
Mockito.clearInvocations(dnNormalizer);
final String casedDn = randomFrom(dn.toLowerCase(Locale.ENGLISH), dn.toUpperCase(Locale.ENGLISH));
runPredicateMultipleTimes(predicate, casedDn);
verify(dnNormalizer, never()).doNormalize(anyString());

// Literal string form changes, it will be parsed again
Mockito.clearInvocations(dnNormalizer);
final String mutatedDn = randomFrom(dn.replace(" ", ""), dn.replace(",", " ,"));
runPredicateMultipleTimes(predicate, mutatedDn);
verify(dnNormalizer, times(1)).doNormalize(mutatedDn);

// Subtree DN is also cached
Mockito.clearInvocations(dnNormalizer);
final String subtreeDn = "*," + randomDn();
runPredicateMultipleTimes(predicate, subtreeDn);
verify(dnNormalizer, times(1)).doNormalize(subtreeDn.substring(2));

// Subtree DN is also keyed by the literal form, so they are space sensitive
Mockito.clearInvocations(dnNormalizer);
final String mutatedSubtreeDn = "*, " + subtreeDn.substring(2);
runPredicateMultipleTimes(predicate, mutatedSubtreeDn);
verify(dnNormalizer, times(1)).doNormalize(mutatedSubtreeDn.substring(2));
}

public void testUserDataUsesCachedDnNormalizer() {
final String userDn = "uid=foo," + randomDn();
final List<String> groups = IntStream.range(0, randomIntBetween(50, 100))
.mapToObj(i -> "gid=g" + i + "," + randomDn())
.distinct()
.collect(Collectors.toList());
final RealmConfig realmConfig = mock(RealmConfig.class);
when(realmConfig.name()).thenReturn(randomAlphaOfLengthBetween(3, 8));
final UserRoleMapper.UserData userData = new UserRoleMapper.UserData(
randomAlphaOfLengthBetween(5, 8),
userDn,
groups,
Collections.emptyMap(),
realmConfig
);
UserRoleMapper.UserData spyUserdata = spy(userData);
final UserRoleMapper.DistinguishedNameNormalizer spyDnNormalizer = spy(userData.getDnNormalizer());
when(spyUserdata.getDnNormalizer()).thenReturn(spyDnNormalizer);

final ExpressionModel expressionModel = spyUserdata.asModel();

// All DNs to be tested should only be parsed once no matter how many groups the userData may have
Mockito.clearInvocations(spyDnNormalizer);
final List<String> dnList = randomList(100, 200, DistinguishedNameNormalizerTests::randomDn).stream()
.distinct()
.collect(Collectors.toList());
final List<FieldValue> fieldValues = dnList.stream()
.map(dn -> randomBoolean() ? new FieldValue(dn) : new FieldValue("*," + dn))
.collect(Collectors.toList());
expressionModel.test("groups", fieldValues);
// Also does not matter how many times the model is tested
expressionModel.test("groups", randomNonEmptySubsetOf(fieldValues));

final ArgumentCaptor<String> argumentCaptor = ArgumentCaptor.forClass(String.class);
verify(spyDnNormalizer, times(dnList.size())).doNormalize(argumentCaptor.capture());
assertThat(argumentCaptor.getAllValues(), equalTo(dnList));
}

private void parseDnMultipleTimes(String dn) {
IntStream.range(0, randomIntBetween(3, 5)).forEach(i -> dnNormalizer.normalize(dn));
}

private void runPredicateMultipleTimes(Predicate<FieldValue> predicate, Object value) {
IntStream.range(0, randomIntBetween(3, 5)).forEach(i -> predicate.test(new FieldValue(value)));
}

private UserRoleMapper.DistinguishedNameNormalizer getDnNormalizer() {
return spy(new UserRoleMapper.DistinguishedNameNormalizer());
}

private static String randomDn() {
return "CN="
+ randomAlphaOfLengthBetween(3, 12)
+ ",OU="
+ randomAlphaOfLength(4)
+ ", O="
+ randomAlphaOfLengthBetween(2, 6)
+ ",dc="
+ randomAlphaOfLength(3);
}

private static String mutateDn(String dn) {
switch (randomIntBetween(1, 4)) {
case 1:
return dn.toLowerCase(Locale.ENGLISH);
case 2:
return dn.toUpperCase(Locale.ENGLISH);
case 3:
return dn.replace(" ", "");
default:
return dn.replace(",", " ,");
}
}
}

0 comments on commit 88e5311

Please sign in to comment.