Skip to content

Commit 1e022be

Browse files
committed
Service Accounts - New CLI tool for managing file tokens (elastic#70454)
This is the second PR for service accounts. It adds a new CLI tool elasticsearch-service-tokens to manage file tokens. The file tokens are stored in the service_tokens file under the config directory. Out of the planned create, remove and list sub-commands, this PR only implements the create function since it is the most important one. The other two sub-commands will be handled in separate PRs.
1 parent 2e307b1 commit 1e022be

File tree

28 files changed

+915
-53
lines changed

28 files changed

+915
-53
lines changed

qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,8 @@ public void test94ElasticsearchNodeExecuteCliNotEsHomeWorkDir() throws Exception
459459
assertThat(result.stdout, containsString("Sets the passwords for reserved users"));
460460
result = sh.run(bin.usersTool + " -h");
461461
assertThat(result.stdout, containsString("Manages elasticsearch file users"));
462+
result = sh.run(bin.serviceTokensTool + " -h");
463+
assertThat(result.stdout, containsString("Manages elasticsearch service account file-tokens"));
462464
};
463465

464466
Platforms.onLinux(action);

qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist
199199
"elasticsearch-sql-cli",
200200
"elasticsearch-syskeygen",
201201
"elasticsearch-users",
202+
"elasticsearch-service-tokens",
202203
"x-pack-env",
203204
"x-pack-security-env",
204205
"x-pack-watcher-env"

qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ private static void verifyDefaultInstallation(Installation es) {
472472
"elasticsearch-sql-cli",
473473
"elasticsearch-syskeygen",
474474
"elasticsearch-users",
475+
"elasticsearch-service-tokens",
475476
"x-pack-env",
476477
"x-pack-security-env",
477478
"x-pack-watcher-env"

qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,5 +189,6 @@ public class Executables {
189189
public final Executable sqlCli = new Executable("elasticsearch-sql-cli");
190190
public final Executable syskeygenTool = new Executable("elasticsearch-syskeygen");
191191
public final Executable usersTool = new Executable("elasticsearch-users");
192+
public final Executable serviceTokensTool = new Executable("elasticsearch-service-tokens");
192193
}
193194
}

qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist
227227
"elasticsearch-sql-cli",
228228
"elasticsearch-syskeygen",
229229
"elasticsearch-users",
230+
"elasticsearch-service-tokens",
230231
"x-pack-env",
231232
"x-pack-security-env",
232233
"x-pack-watcher-env"

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,26 @@ private XPackSettings() {
202202
public static final Setting<Boolean> DIAGNOSE_TRUST_EXCEPTIONS_SETTING = Setting.boolSetting(
203203
"xpack.security.ssl.diagnose.trust", true, Setting.Property.NodeScope);
204204

205+
// TODO: This setting of hashing algorithm can share code with the one for password when pbkdf2_stretch is the default for both
206+
public static final Setting<String> SERVICE_TOKEN_HASHING_ALGORITHM = new Setting<>(
207+
new Setting.SimpleKey("xpack.security.authc.service_token_hashing.algorithm"),
208+
(s) -> "PBKDF2_STRETCH",
209+
Function.identity(),
210+
v -> {
211+
if (Hasher.getAvailableAlgoStoredHash().contains(v.toLowerCase(Locale.ROOT)) == false) {
212+
throw new IllegalArgumentException("Invalid algorithm: " + v + ". Valid values for password hashing are " +
213+
Hasher.getAvailableAlgoStoredHash().toString());
214+
} else if (v.regionMatches(true, 0, "pbkdf2", 0, "pbkdf2".length())) {
215+
try {
216+
SecretKeyFactory.getInstance("PBKDF2withHMACSHA512");
217+
} catch (NoSuchAlgorithmException e) {
218+
throw new IllegalArgumentException(
219+
"Support for PBKDF2WithHMACSHA512 must be available in order to use any of the " +
220+
"PBKDF2 algorithms for the [xpack.security.authc.service_token_hashing.algorithm] setting.", e);
221+
}
222+
}
223+
}, Property.NodeScope);
224+
205225
public static final List<String> DEFAULT_SUPPORTED_PROTOCOLS;
206226

207227
static {

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,27 @@ public void testDefaultSupportedProtocolsWithoutTLSv13() throws Exception {
6161
assertThat(XPackSettings.DEFAULT_SUPPORTED_PROTOCOLS, contains("TLSv1.2", "TLSv1.1"));
6262
}
6363

64+
public void testServiceTokenHashingAlgorithmSettingValidation() {
65+
final boolean isPBKDF2Available = isSecretkeyFactoryAlgoAvailable("PBKDF2WithHMACSHA512");
66+
final String pbkdf2Algo = randomFrom("PBKDF2_10000", "PBKDF2", "PBKDF2_STRETCH");
67+
final Settings settings = Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), pbkdf2Algo).build();
68+
if (isPBKDF2Available) {
69+
assertEquals(pbkdf2Algo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings));
70+
} else {
71+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
72+
() -> XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings));
73+
assertThat(e.getMessage(), containsString("Support for PBKDF2WithHMACSHA512 must be available"));
74+
}
75+
76+
final String bcryptAlgo = randomFrom("BCRYPT", "BCRYPT11");
77+
assertEquals(bcryptAlgo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(
78+
Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), bcryptAlgo).build()));
79+
}
80+
81+
public void testDefaultServiceTokenHashingAlgorithm() {
82+
assertThat(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(Settings.EMPTY), equalTo("PBKDF2_STRETCH"));
83+
}
84+
6485
private boolean isSecretkeyFactoryAlgoAvailable(String algorithmId) {
6586
try {
6687
SecretKeyFactory.getInstance(algorithmId);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/bash
2+
3+
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
4+
# or more contributor license agreements. Licensed under the Elastic License
5+
# 2.0; you may not use this file except in compliance with the Elastic License
6+
# 2.0.
7+
8+
ES_MAIN_CLASS=org.elasticsearch.xpack.security.authc.service.FileTokensTool \
9+
ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \
10+
"`dirname "$0"`"/elasticsearch-cli \
11+
"$@"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
@echo off
2+
3+
rem Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
4+
rem or more contributor license agreements. Licensed under the Elastic License
5+
rem 2.0; you may not use this file except in compliance with the Elastic License
6+
rem 2.0.
7+
8+
setlocal enabledelayedexpansion
9+
setlocal enableextensions
10+
11+
set ES_MAIN_CLASS=org.elasticsearch.xpack.security.authc.service.FileTokensTool
12+
set ES_ADDITIONAL_SOURCES=x-pack-env;x-pack-security-env
13+
call "%~dp0elasticsearch-cli.bat" ^
14+
%%* ^
15+
|| goto exit
16+
17+
endlocal
18+
endlocal
19+
:exit
20+
exit /b %ERRORLEVEL%

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@
201201
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
202202
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
203203
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
204-
import org.elasticsearch.xpack.security.authc.service.ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore;
204+
import org.elasticsearch.xpack.security.authc.service.ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore;
205205
import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator;
206206
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
207207
import org.elasticsearch.xpack.security.authz.AuthorizationService;
@@ -530,7 +530,7 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
530530
components.add(apiKeyService);
531531

532532
final ServiceAccountService serviceAccountService =
533-
new ServiceAccountService(new CompositeServiceAccountsCredentialStore(List.of()));
533+
new ServiceAccountService(new CompositeServiceAccountsTokenStore(List.of()));
534534

535535
final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore,
536536
privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache, apiKeyService,

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import org.elasticsearch.common.settings.Settings;
1717
import org.elasticsearch.common.util.Maps;
1818
import org.elasticsearch.env.Environment;
19-
import org.elasticsearch.watcher.FileChangesListener;
2019
import org.elasticsearch.watcher.FileWatcher;
2120
import org.elasticsearch.watcher.ResourceWatcherService;
2221
import org.elasticsearch.xpack.core.XPackPlugin;
@@ -28,6 +27,7 @@
2827
import org.elasticsearch.xpack.core.security.support.Validation;
2928
import org.elasticsearch.xpack.core.security.support.Validation.Users;
3029
import org.elasticsearch.xpack.core.security.user.User;
30+
import org.elasticsearch.xpack.security.support.FileReloadListener;
3131
import org.elasticsearch.xpack.security.support.SecurityFiles;
3232

3333
import java.io.IOException;
@@ -62,7 +62,7 @@ public FileUserPasswdStore(RealmConfig config, ResourceWatcherService watcherSer
6262
users = parseFileLenient(file, logger, settings);
6363
listeners = new CopyOnWriteArrayList<>(Collections.singletonList(listener));
6464
FileWatcher watcher = new FileWatcher(file.getParent());
65-
watcher.addListener(new FileListener());
65+
watcher.addListener(new FileReloadListener(file, this::tryReload));
6666
try {
6767
watcherService.add(watcher, ResourceWatcherService.Frequency.HIGH);
6868
} catch (IOException e) {
@@ -179,28 +179,13 @@ void notifyRefresh() {
179179
listeners.forEach(Runnable::run);
180180
}
181181

182-
private class FileListener implements FileChangesListener {
183-
@Override
184-
public void onFileCreated(Path file) {
185-
onFileChanged(file);
186-
}
187-
188-
@Override
189-
public void onFileDeleted(Path file) {
190-
onFileChanged(file);
191-
}
192-
193-
@Override
194-
public void onFileChanged(Path file) {
195-
if (file.equals(FileUserPasswdStore.this.file)) {
196-
final Map<String, char[]> previousUsers = users;
197-
users = parseFileLenient(file, logger, settings);
182+
private void tryReload() {
183+
final Map<String, char[]> previousUsers = users;
184+
users = parseFileLenient(file, logger, settings);
198185

199-
if (Maps.deepEquals(previousUsers, users) == false) {
200-
logger.info("users file [{}] changed. updating users... )", file.toAbsolutePath());
201-
notifyRefresh();
202-
}
203-
}
186+
if (Maps.deepEquals(previousUsers, users) == false) {
187+
logger.info("users file [{}] changed. updating users...", file.toAbsolutePath());
188+
notifyRefresh();
204189
}
205190
}
206191
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ final class ElasticServiceAccounts {
3939
null
4040
));
4141

42-
static Map<String, ServiceAccount> ACCOUNTS = List.of(FLEET_ACCOUNT).stream()
43-
.collect(Collectors.toMap(a -> a.id().serviceName(), Function.identity()));;
42+
static final Map<String, ServiceAccount> ACCOUNTS = List.of(FLEET_ACCOUNT).stream()
43+
.collect(Collectors.toMap(a -> a.id().asPrincipal(), Function.identity()));;
4444

4545
private ElasticServiceAccounts() {}
4646

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.authc.service;
9+
10+
import org.apache.logging.log4j.LogManager;
11+
import org.apache.logging.log4j.Logger;
12+
import org.elasticsearch.ElasticsearchException;
13+
import org.elasticsearch.common.Nullable;
14+
import org.elasticsearch.common.util.Maps;
15+
import org.elasticsearch.env.Environment;
16+
import org.elasticsearch.watcher.FileWatcher;
17+
import org.elasticsearch.watcher.ResourceWatcherService;
18+
import org.elasticsearch.xpack.core.XPackPlugin;
19+
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
20+
import org.elasticsearch.xpack.core.security.support.NoOpLogger;
21+
import org.elasticsearch.xpack.security.support.FileLineParser;
22+
import org.elasticsearch.xpack.security.support.FileReloadListener;
23+
import org.elasticsearch.xpack.security.support.SecurityFiles;
24+
25+
import java.io.IOException;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
28+
import java.util.HashMap;
29+
import java.util.Locale;
30+
import java.util.Map;
31+
import java.util.concurrent.CopyOnWriteArrayList;
32+
33+
public class FileServiceAccountsTokenStore implements ServiceAccountsTokenStore {
34+
35+
private static final Logger logger = LogManager.getLogger(FileServiceAccountsTokenStore.class);
36+
37+
private final Path file;
38+
private final CopyOnWriteArrayList<Runnable> listeners;
39+
private volatile Map<String, char[]> tokenHashes;
40+
41+
public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService) {
42+
file = resolveFile(env);
43+
FileWatcher watcher = new FileWatcher(file.getParent());
44+
watcher.addListener(new FileReloadListener(file, this::tryReload));
45+
try {
46+
resourceWatcherService.add(watcher, ResourceWatcherService.Frequency.HIGH);
47+
} catch (IOException e) {
48+
throw new ElasticsearchException("failed to start watching service_tokens file [{}]", e, file.toAbsolutePath());
49+
}
50+
try {
51+
tokenHashes = parseFile(file, logger);
52+
} catch (IOException e) {
53+
throw new IllegalStateException("Failed to load service_tokens file [" + file + "]", e);
54+
}
55+
listeners = new CopyOnWriteArrayList<>();
56+
}
57+
58+
@Override
59+
public boolean authenticate(ServiceAccountToken token) {
60+
return false;
61+
}
62+
63+
public void addListener(Runnable listener) {
64+
listeners.add(listener);
65+
}
66+
67+
private void notifyRefresh() {
68+
listeners.forEach(Runnable::run);
69+
}
70+
71+
private void tryReload() {
72+
final Map<String, char[]> previousTokenHashes = tokenHashes;
73+
tokenHashes = parseFileLenient(file, logger);
74+
if (false == Maps.deepEquals(tokenHashes, previousTokenHashes)) {
75+
logger.info("service tokens file [{}] changed. updating ...", file.toAbsolutePath());
76+
notifyRefresh();
77+
}
78+
}
79+
80+
// package private for testing
81+
Map<String, char[]> getTokenHashes() {
82+
return tokenHashes;
83+
}
84+
85+
static Path resolveFile(Environment env) {
86+
return XPackPlugin.resolveConfigFile(env, "service_tokens");
87+
}
88+
89+
static Map<String, char[]> parseFileLenient(Path path, @Nullable Logger logger) {
90+
try {
91+
return parseFile(path, logger);
92+
} catch (Exception e) {
93+
logger.error("failed to parse service tokens file [{}]. skipping/removing all tokens...",
94+
path.toAbsolutePath());
95+
return Map.of();
96+
}
97+
}
98+
99+
static Map<String, char[]> parseFile(Path path, @Nullable Logger logger) throws IOException {
100+
final Logger thisLogger = logger == null ? NoOpLogger.INSTANCE : logger;
101+
thisLogger.trace("reading service_tokens file [{}]...", path.toAbsolutePath());
102+
if (Files.exists(path) == false) {
103+
thisLogger.trace("file [{}] does not exist", path.toAbsolutePath());
104+
return Map.of();
105+
}
106+
final Map<String, char[]> parsedTokenHashes = new HashMap<>();
107+
FileLineParser.parse(path, (lineNumber, line) -> {
108+
line = line.trim();
109+
final int colon = line.indexOf(':');
110+
if (colon == -1) {
111+
thisLogger.warn("invalid format at line #{} of service_tokens file [{}] - missing ':' character - ", lineNumber, path);
112+
throw new IllegalStateException("Missing ':' character at line #" + lineNumber);
113+
}
114+
final String key = line.substring(0, colon);
115+
// TODO: validate against known service accounts?
116+
char[] hash = new char[line.length() - (colon + 1)];
117+
line.getChars(colon + 1, line.length(), hash, 0);
118+
if (Hasher.resolveFromHash(hash) == Hasher.NOOP) {
119+
thisLogger.warn("skipping plaintext service account token for key [{}]", key);
120+
} else {
121+
thisLogger.trace("parsed tokens for key [{}]", key);
122+
final char[] previousHash = parsedTokenHashes.put(key, hash);
123+
if (previousHash != null) {
124+
thisLogger.warn("found duplicated key [{}], earlier entries are overridden", key);
125+
}
126+
}
127+
});
128+
thisLogger.debug("parsed [{}] tokens from file [{}]", parsedTokenHashes.size(), path.toAbsolutePath());
129+
return Map.copyOf(parsedTokenHashes);
130+
}
131+
132+
static void writeFile(Path path, Map<String, char[]> tokenHashes) {
133+
SecurityFiles.writeFileAtomically(
134+
path, tokenHashes, e -> String.format(Locale.ROOT, "%s:%s", e.getKey(), new String(e.getValue())));
135+
}
136+
}

0 commit comments

Comments
 (0)