Skip to content

Commit 994499d

Browse files
authored
Service Accounts - New CLI tool for managing file tokens (#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 81e661d commit 994499d

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
@@ -433,6 +433,8 @@ public void test94ElasticsearchNodeExecuteCliNotEsHomeWorkDir() throws Exception
433433
assertThat(result.stdout, containsString("Sets the passwords for reserved users"));
434434
result = sh.run(bin.usersTool + " -h");
435435
assertThat(result.stdout, containsString("Manages elasticsearch file users"));
436+
result = sh.run(bin.serviceTokensTool + " -h");
437+
assertThat(result.stdout, containsString("Manages elasticsearch service account file-tokens"));
436438
};
437439

438440
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
@@ -198,6 +198,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist
198198
"elasticsearch-sql-cli",
199199
"elasticsearch-syskeygen",
200200
"elasticsearch-users",
201+
"elasticsearch-service-tokens",
201202
"x-pack-env",
202203
"x-pack-security-env",
203204
"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
@@ -501,6 +501,7 @@ private static void verifyDefaultInstallation(Installation es) {
501501
"elasticsearch-sql-cli",
502502
"elasticsearch-syskeygen",
503503
"elasticsearch-users",
504+
"elasticsearch-service-tokens",
504505
"x-pack-env",
505506
"x-pack-security-env",
506507
"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
@@ -220,6 +220,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist
220220
"elasticsearch-sql-cli",
221221
"elasticsearch-syskeygen",
222222
"elasticsearch-users",
223+
"elasticsearch-service-tokens",
223224
"x-pack-env",
224225
"x-pack-security-env",
225226
"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
@@ -162,6 +162,26 @@ private XPackSettings() {
162162
}
163163
}, Property.NodeScope);
164164

165+
// TODO: This setting of hashing algorithm can share code with the one for password when pbkdf2_stretch is the default for both
166+
public static final Setting<String> SERVICE_TOKEN_HASHING_ALGORITHM = new Setting<>(
167+
new Setting.SimpleKey("xpack.security.authc.service_token_hashing.algorithm"),
168+
(s) -> "PBKDF2_STRETCH",
169+
Function.identity(),
170+
v -> {
171+
if (Hasher.getAvailableAlgoStoredHash().contains(v.toLowerCase(Locale.ROOT)) == false) {
172+
throw new IllegalArgumentException("Invalid algorithm: " + v + ". Valid values for password hashing are " +
173+
Hasher.getAvailableAlgoStoredHash().toString());
174+
} else if (v.regionMatches(true, 0, "pbkdf2", 0, "pbkdf2".length())) {
175+
try {
176+
SecretKeyFactory.getInstance("PBKDF2withHMACSHA512");
177+
} catch (NoSuchAlgorithmException e) {
178+
throw new IllegalArgumentException(
179+
"Support for PBKDF2WithHMACSHA512 must be available in order to use any of the " +
180+
"PBKDF2 algorithms for the [xpack.security.authc.service_token_hashing.algorithm] setting.", e);
181+
}
182+
}
183+
}, Property.NodeScope);
184+
165185
public static final List<String> DEFAULT_SUPPORTED_PROTOCOLS;
166186

167187
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
@@ -76,6 +76,27 @@ public void testDefaultSupportedProtocols() {
7676
}
7777
}
7878

79+
public void testServiceTokenHashingAlgorithmSettingValidation() {
80+
final boolean isPBKDF2Available = isSecretkeyFactoryAlgoAvailable("PBKDF2WithHMACSHA512");
81+
final String pbkdf2Algo = randomFrom("PBKDF2_10000", "PBKDF2", "PBKDF2_STRETCH");
82+
final Settings settings = Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), pbkdf2Algo).build();
83+
if (isPBKDF2Available) {
84+
assertEquals(pbkdf2Algo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings));
85+
} else {
86+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
87+
() -> XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings));
88+
assertThat(e.getMessage(), containsString("Support for PBKDF2WithHMACSHA512 must be available"));
89+
}
90+
91+
final String bcryptAlgo = randomFrom("BCRYPT", "BCRYPT11");
92+
assertEquals(bcryptAlgo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(
93+
Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), bcryptAlgo).build()));
94+
}
95+
96+
public void testDefaultServiceTokenHashingAlgorithm() {
97+
assertThat(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(Settings.EMPTY), equalTo("PBKDF2_STRETCH"));
98+
}
99+
79100
private boolean isSecretkeyFactoryAlgoAvailable(String algorithmId) {
80101
try {
81102
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;
@@ -492,7 +492,7 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
492492
components.add(apiKeyService);
493493

494494
final ServiceAccountService serviceAccountService =
495-
new ServiceAccountService(new CompositeServiceAccountsCredentialStore(List.of()));
495+
new ServiceAccountService(new CompositeServiceAccountsTokenStore(List.of()));
496496

497497
final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore,
498498
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)