Skip to content

Commit 91ab88e

Browse files
authored
Security: cache users in PKI realm (elastic/x-pack-elasticsearch#4428)
The PKI realm has never been a caching realm as the need had not presented itself until now. The PKI realm relies on role mappings to map the DN from a certificate to roles so that the users have the appropriate access permissions. Without caching, this role mapping will happen on every request. For file based role mappings, this is not an issue as the mappings are based on equality checks for the DN. However, the design of the API based role mappings allows for more complex matches. These matches are implemented using automata, which are built on every request that needs role mappings. Building automata is an expensive operation and in combination with the PKI realm's lack of caching leads to a significant performance impact. The change in this commit makes the PkiRealm a caching realm using the same pattern as other caching realms. The cache provided by elasticsearch core is used to map the fingerprint of a certificate to the user that was resolved from this certificate. The semantics of modifications to this cache during iteration requires that we use a read-write lock to protect access. There can be multiple concurrent modifications and retrievals but iteration must be protected from any attempts to modify the cache. Additionally, some PKI tests were converted to single node tests as part of this change. One test only used a single node and the other did not require multiple nodes. relates elastic/x-pack-elasticsearch#4406 Original commit: elastic/x-pack-elasticsearch@214772e
1 parent cdf41cf commit 91ab88e

File tree

11 files changed

+212
-82
lines changed

11 files changed

+212
-82
lines changed

docs/en/security/authentication/user-cache.asciidoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ remote authentication service or hitting the disk for every incoming request.
66
You can configure characteristics of the user cache with the `cache.ttl`,
77
`cache.max_users`, and `cache.hash_algo` realm settings.
88

9-
NOTE: PKI realms do not use the user cache.
9+
NOTE: PKI realms do not cache user credentials but do cache the resolved user
10+
object to avoid unnecessarily needing to perform role mapping on each request.
1011

1112
The cached user credentials are hashed in memory. By default, {security} uses a
1213
salted `sha-256` hash algorithm. You can use a different hashing algorithm by

docs/en/settings/security-settings.asciidoc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,15 @@ Specifies the {xpack-ref}/security-files.html[location] of the
640640
{xpack-ref}/mapping-roles.html[YAML role mapping configuration file].
641641
Defaults to `CONFIG_DIR/x-pack/role_mapping.yml`.
642642

643+
`cache.ttl`::
644+
Specifies the time-to-live for cached user entries. Use the
645+
standard Elasticsearch {ref}/common-options.html#time-units[time units]).
646+
Defaults to `20m`.
647+
648+
`cache.max_users`::
649+
Specifies the maximum number of user entries that the cache can contain.
650+
Defaults to `100000`.
651+
643652
[[ref-saml-settings]]
644653
[float]
645654
===== SAML Realm Settings

plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package org.elasticsearch.xpack.core.security.authc.pki;
77

88
import org.elasticsearch.common.settings.Setting;
9+
import org.elasticsearch.common.unit.TimeValue;
910
import org.elasticsearch.xpack.core.security.authc.support.mapper.CompositeRoleMapperSettings;
1011
import org.elasticsearch.xpack.core.ssl.SSLConfigurationSettings;
1112

@@ -18,6 +19,11 @@ public final class PkiRealmSettings {
1819
public static final String DEFAULT_USERNAME_PATTERN = "CN=(.*?)(?:,|$)";
1920
public static final Setting<Pattern> USERNAME_PATTERN_SETTING = new Setting<>("username_pattern", DEFAULT_USERNAME_PATTERN,
2021
s -> Pattern.compile(s, Pattern.CASE_INSENSITIVE), Setting.Property.NodeScope);
22+
private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20);
23+
public static final Setting<TimeValue> CACHE_TTL_SETTING = Setting.timeSetting("cache.ttl", DEFAULT_TTL, Setting.Property.NodeScope);
24+
private static final int DEFAULT_MAX_USERS = 100_000; //100k users
25+
public static final Setting<Integer> CACHE_MAX_USERS_SETTING = Setting.intSetting("cache.max_users", DEFAULT_MAX_USERS,
26+
Setting.Property.NodeScope);
2127
public static final SSLConfigurationSettings SSL_SETTINGS = SSLConfigurationSettings.withoutPrefix();
2228

2329
private PkiRealmSettings() {}
@@ -28,6 +34,8 @@ private PkiRealmSettings() {}
2834
public static Set<Setting<?>> getSettings() {
2935
Set<Setting<?>> settings = new HashSet<>();
3036
settings.add(USERNAME_PATTERN_SETTING);
37+
settings.add(CACHE_TTL_SETTING);
38+
settings.add(CACHE_MAX_USERS_SETTING);
3139

3240
settings.add(SSL_SETTINGS.truststorePath);
3341
settings.add(SSL_SETTINGS.truststorePassword);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
package org.elasticsearch.xpack.security.authc;
7+
8+
import org.apache.lucene.util.BytesRef;
9+
import org.apache.lucene.util.StringHelper;
10+
11+
import java.util.Arrays;
12+
13+
/**
14+
* Simple wrapper around bytes so that it can be used as a cache key. The hashCode is computed
15+
* once upon creation and cached.
16+
*/
17+
public class BytesKey {
18+
19+
final byte[] bytes;
20+
private final int hashCode;
21+
22+
public BytesKey(byte[] bytes) {
23+
this.bytes = bytes;
24+
this.hashCode = StringHelper.murmurhash3_x86_32(bytes, 0, bytes.length, StringHelper.GOOD_FAST_HASH_SEED);
25+
}
26+
27+
@Override
28+
public int hashCode() {
29+
return hashCode;
30+
}
31+
32+
@Override
33+
public boolean equals(Object other) {
34+
if (other == null) {
35+
return false;
36+
}
37+
if (other instanceof BytesKey == false) {
38+
return false;
39+
}
40+
41+
BytesKey otherBytes = (BytesKey) other;
42+
return Arrays.equals(otherBytes.bytes, bytes);
43+
}
44+
45+
@Override
46+
public String toString() {
47+
return new BytesRef(bytes).toString();
48+
}
49+
}

plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,44 +1165,6 @@ public void onFailure(Exception e) {
11651165
}
11661166
}
11671167

1168-
/**
1169-
* Simple wrapper around bytes so that it can be used as a cache key. The hashCode is computed
1170-
* once upon creation and cached.
1171-
*/
1172-
static class BytesKey {
1173-
1174-
final byte[] bytes;
1175-
private final int hashCode;
1176-
1177-
BytesKey(byte[] bytes) {
1178-
this.bytes = bytes;
1179-
this.hashCode = StringHelper.murmurhash3_x86_32(bytes, 0, bytes.length, StringHelper.GOOD_FAST_HASH_SEED);
1180-
}
1181-
1182-
@Override
1183-
public int hashCode() {
1184-
return hashCode;
1185-
}
1186-
1187-
@Override
1188-
public boolean equals(Object other) {
1189-
if (other == null) {
1190-
return false;
1191-
}
1192-
if (other instanceof BytesKey == false) {
1193-
return false;
1194-
}
1195-
1196-
BytesKey otherBytes = (BytesKey) other;
1197-
return Arrays.equals(otherBytes.bytes, bytes);
1198-
}
1199-
1200-
@Override
1201-
public String toString() {
1202-
return new BytesRef(bytes).toString();
1203-
}
1204-
}
1205-
12061168
/**
12071169
* Creates a new key unless present that is newer than the current active key and returns the corresponding metadata. Note:
12081170
* this method doesn't modify the metadata used in this token service. See {@link #refreshMetaData(TokenMetaData)}

plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@
1111
import org.elasticsearch.ElasticsearchException;
1212
import org.elasticsearch.action.ActionListener;
1313
import org.elasticsearch.common.Strings;
14+
import org.elasticsearch.common.cache.Cache;
15+
import org.elasticsearch.common.cache.CacheBuilder;
16+
import org.elasticsearch.common.hash.MessageDigests;
1417
import org.elasticsearch.common.settings.SecureString;
1518
import org.elasticsearch.common.settings.Settings;
19+
import org.elasticsearch.common.util.concurrent.ReleasableLock;
1620
import org.elasticsearch.common.util.concurrent.ThreadContext;
1721
import org.elasticsearch.env.Environment;
1822
import org.elasticsearch.watcher.ResourceWatcherService;
@@ -25,32 +29,52 @@
2529
import org.elasticsearch.xpack.core.security.user.User;
2630
import org.elasticsearch.xpack.core.ssl.CertUtils;
2731
import org.elasticsearch.xpack.core.ssl.SSLConfigurationSettings;
32+
import org.elasticsearch.xpack.security.authc.BytesKey;
33+
import org.elasticsearch.xpack.security.authc.support.CachingRealm;
2834
import org.elasticsearch.xpack.security.authc.support.UserRoleMapper;
2935
import org.elasticsearch.xpack.security.authc.support.mapper.CompositeRoleMapper;
3036
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
3137

3238
import javax.net.ssl.X509TrustManager;
3339

40+
import java.security.MessageDigest;
3441
import java.security.cert.Certificate;
42+
import java.security.cert.CertificateEncodingException;
3543
import java.security.cert.CertificateException;
3644
import java.security.cert.X509Certificate;
3745
import java.util.Collections;
46+
import java.util.Iterator;
3847
import java.util.List;
3948
import java.util.Map;
49+
import java.util.concurrent.locks.ReadWriteLock;
50+
import java.util.concurrent.locks.ReentrantReadWriteLock;
4051
import java.util.regex.Matcher;
4152
import java.util.regex.Pattern;
4253

43-
public class PkiRealm extends Realm {
54+
public class PkiRealm extends Realm implements CachingRealm {
4455

4556
public static final String PKI_CERT_HEADER_NAME = "__SECURITY_CLIENT_CERTIFICATE";
4657

4758
// For client based cert validation, the auth type must be specified but UNKNOWN is an acceptable value
4859
private static final String AUTH_TYPE = "UNKNOWN";
4960

61+
// the lock is used in an odd manner; when iterating over the cache we cannot have modifiers other than deletes using
62+
// the iterator but when not iterating we can modify the cache without external locking. When making normal modifications to the cache
63+
// the read lock is obtained so that we can allow concurrent modifications; however when we need to iterate over the keys or values of
64+
// the cache the write lock must obtained to prevent any modifications
65+
private final ReleasableLock readLock;
66+
private final ReleasableLock writeLock;
67+
68+
{
69+
final ReadWriteLock iterationLock = new ReentrantReadWriteLock();
70+
readLock = new ReleasableLock(iterationLock.readLock());
71+
writeLock = new ReleasableLock(iterationLock.writeLock());
72+
}
73+
5074
private final X509TrustManager trustManager;
5175
private final Pattern principalPattern;
5276
private final UserRoleMapper roleMapper;
53-
77+
private final Cache<BytesKey, User> cache;
5478

5579
public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, NativeRoleMappingStore nativeRoleMappingStore) {
5680
this(config, new CompositeRoleMapper(PkiRealmSettings.TYPE, config, watcherService, nativeRoleMappingStore));
@@ -62,6 +86,10 @@ public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, Nativ
6286
this.trustManager = trustManagers(config);
6387
this.principalPattern = PkiRealmSettings.USERNAME_PATTERN_SETTING.get(config.settings());
6488
this.roleMapper = roleMapper;
89+
this.cache = CacheBuilder.<BytesKey, User>builder()
90+
.setExpireAfterWrite(PkiRealmSettings.CACHE_TTL_SETTING.get(config.settings()))
91+
.setMaximumWeight(PkiRealmSettings.CACHE_MAX_USERS_SETTING.get(config.settings()))
92+
.build();
6593
}
6694

6795
@Override
@@ -77,18 +105,28 @@ public X509AuthenticationToken token(ThreadContext context) {
77105
@Override
78106
public void authenticate(AuthenticationToken authToken, ActionListener<AuthenticationResult> listener) {
79107
X509AuthenticationToken token = (X509AuthenticationToken)authToken;
80-
if (isCertificateChainTrusted(trustManager, token, logger) == false) {
81-
listener.onResponse(AuthenticationResult.unsuccessful("Certificate for " + token.dn() + " is not trusted", null));
82-
} else {
83-
final Map<String, Object> metadata = Collections.singletonMap("pki_dn", token.dn());
84-
final UserRoleMapper.UserData user = new UserRoleMapper.UserData(token.principal(),
85-
token.dn(), Collections.emptySet(), metadata, this.config);
86-
roleMapper.resolveRoles(user, ActionListener.wrap(
87-
roles -> listener.onResponse(AuthenticationResult.success(
88-
new User(token.principal(), roles.toArray(new String[roles.size()]), null, null, metadata, true)
89-
)),
90-
listener::onFailure
91-
));
108+
try {
109+
final BytesKey fingerprint = computeFingerprint(token.credentials()[0]);
110+
User user = cache.get(fingerprint);
111+
if (user != null) {
112+
listener.onResponse(AuthenticationResult.success(user));
113+
} else if (isCertificateChainTrusted(trustManager, token, logger) == false) {
114+
listener.onResponse(AuthenticationResult.unsuccessful("Certificate for " + token.dn() + " is not trusted", null));
115+
} else {
116+
final Map<String, Object> metadata = Collections.singletonMap("pki_dn", token.dn());
117+
final UserRoleMapper.UserData userData = new UserRoleMapper.UserData(token.principal(),
118+
token.dn(), Collections.emptySet(), metadata, this.config);
119+
roleMapper.resolveRoles(userData, ActionListener.wrap(roles -> {
120+
final User computedUser =
121+
new User(token.principal(), roles.toArray(new String[roles.size()]), null, null, metadata, true);
122+
try (ReleasableLock ignored = readLock.acquire()) {
123+
cache.put(fingerprint, computedUser);
124+
}
125+
listener.onResponse(AuthenticationResult.success(computedUser));
126+
}, listener::onFailure));
127+
}
128+
} catch (CertificateEncodingException e) {
129+
listener.onResponse(AuthenticationResult.unsuccessful("Certificate for " + token.dn() + " has encoding issues", e));
92130
}
93131
}
94132

@@ -196,4 +234,29 @@ private static X509TrustManager trustManagersFromCAs(Settings settings, Environm
196234
}
197235
}
198236

237+
@Override
238+
public void expire(String username) {
239+
try (ReleasableLock ignored = writeLock.acquire()) {
240+
Iterator<User> userIterator = cache.values().iterator();
241+
while (userIterator.hasNext()) {
242+
if (userIterator.next().principal().equals(username)) {
243+
userIterator.remove();
244+
// do not break since there is no guarantee username is unique in this realm
245+
}
246+
}
247+
}
248+
}
249+
250+
@Override
251+
public void expireAll() {
252+
try (ReleasableLock ignored = readLock.acquire()) {
253+
cache.invalidateAll();
254+
}
255+
}
256+
257+
private static BytesKey computeFingerprint(X509Certificate certificate) throws CertificateEncodingException {
258+
MessageDigest digest = MessageDigests.sha256();
259+
digest.update(certificate.getEncoded());
260+
return new BytesKey(digest.digest());
261+
}
199262
}

plugin/security/src/test/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.elasticsearch.action.admin.cluster.node.info.NodesInfoResponse;
1313
import org.elasticsearch.client.Client;
1414
import org.elasticsearch.client.RestClient;
15+
import org.elasticsearch.client.RestClientBuilder;
1516
import org.elasticsearch.common.network.NetworkAddress;
1617
import org.elasticsearch.common.settings.MockSecureSettings;
1718
import org.elasticsearch.common.settings.SecureString;
@@ -167,6 +168,12 @@ protected Settings nodeSettings() {
167168
return builder.build();
168169
}
169170

171+
protected Settings transportClientSettings() {
172+
return Settings.builder()
173+
.put(customSecuritySettingsSource.transportClientSettings())
174+
.build();
175+
}
176+
170177
@Override
171178
protected Collection<Class<? extends Plugin>> getPlugins() {
172179
return customSecuritySettingsSource.nodePlugins();
@@ -271,22 +278,31 @@ protected RestClient getRestClient() {
271278
return getRestClient(client());
272279
}
273280

281+
protected RestClient createRestClient(RestClientBuilder.HttpClientConfigCallback httpClientConfigCallback, String protocol) {
282+
return createRestClient(client(), httpClientConfigCallback, protocol);
283+
}
284+
274285
private static synchronized RestClient getRestClient(Client client) {
275286
if (restClient == null) {
276-
restClient = createRestClient(client);
287+
restClient = createRestClient(client, null, "http");
277288
}
278289
return restClient;
279290
}
280291

281-
private static RestClient createRestClient(Client client) {
292+
private static RestClient createRestClient(Client client, RestClientBuilder.HttpClientConfigCallback httpClientConfigCallback,
293+
String protocol) {
282294
NodesInfoResponse nodesInfoResponse = client.admin().cluster().prepareNodesInfo().get();
283295
assertFalse(nodesInfoResponse.hasFailures());
284296
assertEquals(nodesInfoResponse.getNodes().size(), 1);
285297
NodeInfo node = nodesInfoResponse.getNodes().get(0);
286298
assertNotNull(node.getHttp());
287299
TransportAddress publishAddress = node.getHttp().address().publishAddress();
288300
InetSocketAddress address = publishAddress.address();
289-
final HttpHost host = new HttpHost(NetworkAddress.format(address.getAddress()), address.getPort(), "http");
290-
return RestClient.builder(host).build();
301+
final HttpHost host = new HttpHost(NetworkAddress.format(address.getAddress()), address.getPort(), protocol);
302+
RestClientBuilder builder = RestClient.builder(host);
303+
if (httpClientConfigCallback != null) {
304+
builder.setHttpClientConfigCallback(httpClientConfigCallback);
305+
}
306+
return builder.build();
291307
}
292308
}

plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@
5151
import org.elasticsearch.xpack.core.security.user.User;
5252
import org.elasticsearch.xpack.core.watcher.watch.ClockMock;
5353
import org.elasticsearch.xpack.security.SecurityLifecycleService;
54-
import org.elasticsearch.xpack.security.authc.TokenService.BytesKey;
5554
import org.junit.AfterClass;
5655
import org.junit.Before;
5756
import org.junit.BeforeClass;

0 commit comments

Comments
 (0)