瀏覽代碼

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.
Yang Wang 4 年之前
父節點
當前提交
994499d7cc
共有 28 個文件被更改,包括 915 次插入53 次删除
  1. 2 0
      qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java
  2. 1 0
      qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java
  3. 1 0
      qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java
  4. 1 0
      qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java
  5. 1 0
      qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java
  6. 20 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java
  7. 21 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java
  8. 11 0
      x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens
  9. 20 0
      x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat
  10. 2 2
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  11. 8 23
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java
  12. 2 2
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java
  13. 136 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java
  14. 133 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java
  15. 17 8
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java
  16. 4 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java
  17. 4 4
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java
  18. 33 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java
  19. 40 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java
  20. 2 2
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java
  21. 6 6
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java
  22. 1 1
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java
  23. 184 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java
  24. 10 5
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java
  25. 39 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java
  26. 37 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java
  27. 6 0
      x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens
  28. 173 0
      x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java

+ 2 - 0
qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java

@@ -433,6 +433,8 @@ public class ArchiveTests extends PackagingTestCase {
             assertThat(result.stdout, containsString("Sets the passwords for reserved users"));
             result = sh.run(bin.usersTool + " -h");
             assertThat(result.stdout, containsString("Manages elasticsearch file users"));
+            result = sh.run(bin.serviceTokensTool + " -h");
+            assertThat(result.stdout, containsString("Manages elasticsearch service account file-tokens"));
         };
 
         Platforms.onLinux(action);

+ 1 - 0
qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java

@@ -198,6 +198,7 @@ public class Archives {
             "elasticsearch-sql-cli",
             "elasticsearch-syskeygen",
             "elasticsearch-users",
+            "elasticsearch-service-tokens",
             "x-pack-env",
             "x-pack-security-env",
             "x-pack-watcher-env"

+ 1 - 0
qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java

@@ -501,6 +501,7 @@ public class Docker {
             "elasticsearch-sql-cli",
             "elasticsearch-syskeygen",
             "elasticsearch-users",
+            "elasticsearch-service-tokens",
             "x-pack-env",
             "x-pack-security-env",
             "x-pack-watcher-env"

+ 1 - 0
qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java

@@ -189,5 +189,6 @@ public class Installation {
         public final Executable sqlCli = new Executable("elasticsearch-sql-cli");
         public final Executable syskeygenTool = new Executable("elasticsearch-syskeygen");
         public final Executable usersTool = new Executable("elasticsearch-users");
+        public final Executable serviceTokensTool = new Executable("elasticsearch-service-tokens");
     }
 }

+ 1 - 0
qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java

@@ -220,6 +220,7 @@ public class Packages {
             "elasticsearch-sql-cli",
             "elasticsearch-syskeygen",
             "elasticsearch-users",
+            "elasticsearch-service-tokens",
             "x-pack-env",
             "x-pack-security-env",
             "x-pack-watcher-env"

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

@@ -162,6 +162,26 @@ public class XPackSettings {
             }
         }, Property.NodeScope);
 
+    // TODO: This setting of hashing algorithm can share code with the one for password when pbkdf2_stretch is the default for both
+    public static final Setting<String> SERVICE_TOKEN_HASHING_ALGORITHM = new Setting<>(
+        new Setting.SimpleKey("xpack.security.authc.service_token_hashing.algorithm"),
+        (s) -> "PBKDF2_STRETCH",
+        Function.identity(),
+        v -> {
+            if (Hasher.getAvailableAlgoStoredHash().contains(v.toLowerCase(Locale.ROOT)) == false) {
+                throw new IllegalArgumentException("Invalid algorithm: " + v + ". Valid values for password hashing are " +
+                    Hasher.getAvailableAlgoStoredHash().toString());
+            } else if (v.regionMatches(true, 0, "pbkdf2", 0, "pbkdf2".length())) {
+                try {
+                    SecretKeyFactory.getInstance("PBKDF2withHMACSHA512");
+                } catch (NoSuchAlgorithmException e) {
+                    throw new IllegalArgumentException(
+                        "Support for PBKDF2WithHMACSHA512 must be available in order to use any of the " +
+                            "PBKDF2 algorithms for the [xpack.security.authc.service_token_hashing.algorithm] setting.", e);
+                }
+            }
+        }, Property.NodeScope);
+
     public static final List<String> DEFAULT_SUPPORTED_PROTOCOLS;
 
     static {

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

@@ -76,6 +76,27 @@ public class XPackSettingsTests extends ESTestCase {
         }
     }
 
+    public void testServiceTokenHashingAlgorithmSettingValidation() {
+        final boolean isPBKDF2Available = isSecretkeyFactoryAlgoAvailable("PBKDF2WithHMACSHA512");
+        final String pbkdf2Algo = randomFrom("PBKDF2_10000", "PBKDF2", "PBKDF2_STRETCH");
+        final Settings settings = Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), pbkdf2Algo).build();
+        if (isPBKDF2Available) {
+            assertEquals(pbkdf2Algo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings));
+        } else {
+            IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
+                () -> XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings));
+            assertThat(e.getMessage(), containsString("Support for PBKDF2WithHMACSHA512 must be available"));
+        }
+
+        final String bcryptAlgo = randomFrom("BCRYPT", "BCRYPT11");
+        assertEquals(bcryptAlgo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(
+            Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), bcryptAlgo).build()));
+    }
+
+    public void testDefaultServiceTokenHashingAlgorithm() {
+        assertThat(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(Settings.EMPTY), equalTo("PBKDF2_STRETCH"));
+    }
+
     private boolean isSecretkeyFactoryAlgoAvailable(String algorithmId) {
         try {
             SecretKeyFactory.getInstance(algorithmId);

+ 11 - 0
x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens

@@ -0,0 +1,11 @@
+#!/bin/bash
+
+# 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.
+
+ES_MAIN_CLASS=org.elasticsearch.xpack.security.authc.service.FileTokensTool \
+  ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \
+  "`dirname "$0"`"/elasticsearch-cli \
+  "$@"

+ 20 - 0
x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat

@@ -0,0 +1,20 @@
+@echo off
+
+rem Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+rem or more contributor license agreements. Licensed under the Elastic License
+rem 2.0; you may not use this file except in compliance with the Elastic License
+rem 2.0.
+
+setlocal enabledelayedexpansion
+setlocal enableextensions
+
+set ES_MAIN_CLASS=org.elasticsearch.xpack.security.authc.service.FileTokensTool
+set ES_ADDITIONAL_SOURCES=x-pack-env;x-pack-security-env
+call "%~dp0elasticsearch-cli.bat" ^
+  %%* ^
+  || goto exit
+
+endlocal
+endlocal
+:exit
+exit /b %ERRORLEVEL%

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

@@ -201,7 +201,7 @@ import org.elasticsearch.xpack.security.authc.TokenService;
 import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
 import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
 import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
-import org.elasticsearch.xpack.security.authc.service.ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore;
+import org.elasticsearch.xpack.security.authc.service.ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore;
 import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator;
 import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
 import org.elasticsearch.xpack.security.authz.AuthorizationService;
@@ -492,7 +492,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
         components.add(apiKeyService);
 
         final ServiceAccountService serviceAccountService =
-            new ServiceAccountService(new CompositeServiceAccountsCredentialStore(List.of()));
+            new ServiceAccountService(new CompositeServiceAccountsTokenStore(List.of()));
 
         final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore,
             privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache, apiKeyService,

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

@@ -16,7 +16,6 @@ import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.util.Maps;
 import org.elasticsearch.env.Environment;
-import org.elasticsearch.watcher.FileChangesListener;
 import org.elasticsearch.watcher.FileWatcher;
 import org.elasticsearch.watcher.ResourceWatcherService;
 import org.elasticsearch.xpack.core.XPackPlugin;
@@ -28,6 +27,7 @@ import org.elasticsearch.xpack.core.security.support.NoOpLogger;
 import org.elasticsearch.xpack.core.security.support.Validation;
 import org.elasticsearch.xpack.core.security.support.Validation.Users;
 import org.elasticsearch.xpack.core.security.user.User;
+import org.elasticsearch.xpack.security.support.FileReloadListener;
 import org.elasticsearch.xpack.security.support.SecurityFiles;
 
 import java.io.IOException;
@@ -62,7 +62,7 @@ public class FileUserPasswdStore {
         users = parseFileLenient(file, logger, settings);
         listeners = new CopyOnWriteArrayList<>(Collections.singletonList(listener));
         FileWatcher watcher = new FileWatcher(file.getParent());
-        watcher.addListener(new FileListener());
+        watcher.addListener(new FileReloadListener(file, this::tryReload));
         try {
             watcherService.add(watcher, ResourceWatcherService.Frequency.HIGH);
         } catch (IOException e) {
@@ -179,28 +179,13 @@ public class FileUserPasswdStore {
         listeners.forEach(Runnable::run);
     }
 
-    private class FileListener implements FileChangesListener {
-        @Override
-        public void onFileCreated(Path file) {
-            onFileChanged(file);
-        }
-
-        @Override
-        public void onFileDeleted(Path file) {
-            onFileChanged(file);
-        }
-
-        @Override
-        public void onFileChanged(Path file) {
-            if (file.equals(FileUserPasswdStore.this.file)) {
-                final Map<String, char[]> previousUsers = users;
-                users = parseFileLenient(file, logger, settings);
+    private void tryReload() {
+        final Map<String, char[]> previousUsers = users;
+        users = parseFileLenient(file, logger, settings);
 
-                if (Maps.deepEquals(previousUsers, users) == false) {
-                    logger.info("users file [{}] changed. updating users... )", file.toAbsolutePath());
-                    notifyRefresh();
-                }
-            }
+        if (Maps.deepEquals(previousUsers, users) == false) {
+            logger.info("users file [{}] changed. updating users...", file.toAbsolutePath());
+            notifyRefresh();
         }
     }
 }

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

@@ -39,8 +39,8 @@ final class ElasticServiceAccounts {
             null
         ));
 
-    static Map<String, ServiceAccount> ACCOUNTS = List.of(FLEET_ACCOUNT).stream()
-        .collect(Collectors.toMap(a -> a.id().serviceName(), Function.identity()));;
+    static final Map<String, ServiceAccount> ACCOUNTS = List.of(FLEET_ACCOUNT).stream()
+        .collect(Collectors.toMap(a -> a.id().asPrincipal(), Function.identity()));;
 
     private ElasticServiceAccounts() {}
 

+ 136 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java

@@ -0,0 +1,136 @@
+/*
+ * 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.security.authc.service;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.common.Nullable;
+import org.elasticsearch.common.util.Maps;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.watcher.FileWatcher;
+import org.elasticsearch.watcher.ResourceWatcherService;
+import org.elasticsearch.xpack.core.XPackPlugin;
+import org.elasticsearch.xpack.core.security.authc.support.Hasher;
+import org.elasticsearch.xpack.core.security.support.NoOpLogger;
+import org.elasticsearch.xpack.security.support.FileLineParser;
+import org.elasticsearch.xpack.security.support.FileReloadListener;
+import org.elasticsearch.xpack.security.support.SecurityFiles;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+public class FileServiceAccountsTokenStore implements ServiceAccountsTokenStore {
+
+    private static final Logger logger = LogManager.getLogger(FileServiceAccountsTokenStore.class);
+
+    private final Path file;
+    private final CopyOnWriteArrayList<Runnable> listeners;
+    private volatile Map<String, char[]> tokenHashes;
+
+    public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService) {
+        file = resolveFile(env);
+        FileWatcher watcher = new FileWatcher(file.getParent());
+        watcher.addListener(new FileReloadListener(file, this::tryReload));
+        try {
+            resourceWatcherService.add(watcher, ResourceWatcherService.Frequency.HIGH);
+        } catch (IOException e) {
+            throw new ElasticsearchException("failed to start watching service_tokens file [{}]", e, file.toAbsolutePath());
+        }
+        try {
+            tokenHashes = parseFile(file, logger);
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to load service_tokens file [" + file + "]", e);
+        }
+        listeners = new CopyOnWriteArrayList<>();
+    }
+
+    @Override
+    public boolean authenticate(ServiceAccountToken token) {
+        return false;
+    }
+
+    public void addListener(Runnable listener) {
+        listeners.add(listener);
+    }
+
+    private void notifyRefresh() {
+        listeners.forEach(Runnable::run);
+    }
+
+    private void tryReload() {
+        final Map<String, char[]> previousTokenHashes = tokenHashes;
+        tokenHashes = parseFileLenient(file, logger);
+        if (false == Maps.deepEquals(tokenHashes, previousTokenHashes)) {
+            logger.info("service tokens file [{}] changed. updating ...", file.toAbsolutePath());
+            notifyRefresh();
+        }
+    }
+
+    // package private for testing
+    Map<String, char[]> getTokenHashes() {
+        return tokenHashes;
+    }
+
+    static Path resolveFile(Environment env) {
+        return XPackPlugin.resolveConfigFile(env, "service_tokens");
+    }
+
+    static Map<String, char[]> parseFileLenient(Path path, @Nullable Logger logger) {
+        try {
+            return parseFile(path, logger);
+        } catch (Exception e) {
+            logger.error("failed to parse service tokens file [{}]. skipping/removing all tokens...",
+                path.toAbsolutePath());
+            return Map.of();
+        }
+    }
+
+    static Map<String, char[]> parseFile(Path path, @Nullable Logger logger) throws IOException {
+        final Logger thisLogger = logger == null ? NoOpLogger.INSTANCE : logger;
+        thisLogger.trace("reading service_tokens file [{}]...", path.toAbsolutePath());
+        if (Files.exists(path) == false) {
+            thisLogger.trace("file [{}] does not exist", path.toAbsolutePath());
+            return Map.of();
+        }
+        final Map<String, char[]> parsedTokenHashes = new HashMap<>();
+        FileLineParser.parse(path, (lineNumber, line) -> {
+            line = line.trim();
+            final int colon = line.indexOf(':');
+            if (colon == -1) {
+                thisLogger.warn("invalid format at line #{} of service_tokens file [{}] - missing ':' character - ", lineNumber, path);
+                throw new IllegalStateException("Missing ':' character at line #" + lineNumber);
+            }
+            final String key = line.substring(0, colon);
+            // TODO: validate against known service accounts?
+            char[] hash = new char[line.length() - (colon + 1)];
+            line.getChars(colon + 1, line.length(), hash, 0);
+            if (Hasher.resolveFromHash(hash) == Hasher.NOOP) {
+                thisLogger.warn("skipping plaintext service account token for key [{}]", key);
+            } else {
+                thisLogger.trace("parsed tokens for key [{}]", key);
+                final char[] previousHash = parsedTokenHashes.put(key, hash);
+                if (previousHash != null) {
+                    thisLogger.warn("found duplicated key [{}], earlier entries are overridden", key);
+                }
+            }
+        });
+        thisLogger.debug("parsed [{}] tokens from file [{}]", parsedTokenHashes.size(), path.toAbsolutePath());
+        return Map.copyOf(parsedTokenHashes);
+    }
+
+    static void writeFile(Path path, Map<String, char[]> tokenHashes) {
+        SecurityFiles.writeFileAtomically(
+            path, tokenHashes, e -> String.format(Locale.ROOT, "%s:%s", e.getKey(), new String(e.getValue())));
+    }
+}

+ 133 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java

@@ -0,0 +1,133 @@
+/*
+ * 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.security.authc.service;
+
+import joptsimple.OptionSet;
+import joptsimple.OptionSpec;
+import org.elasticsearch.cli.EnvironmentAwareCommand;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.LoggingAwareMultiCommand;
+import org.elasticsearch.cli.Terminal;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.UUIDs;
+import org.elasticsearch.common.collect.Tuple;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.authc.support.Hasher;
+import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;
+import org.elasticsearch.xpack.security.support.FileAttributesChecker;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class FileTokensTool extends LoggingAwareMultiCommand {
+
+    public static void main(String[] args) throws Exception {
+        exit(new FileTokensTool().main(args, Terminal.DEFAULT));
+    }
+
+    public FileTokensTool() {
+        super("Manages elasticsearch service account file-tokens");
+        subcommands.put("create", newCreateFileTokenCommand());
+        subcommands.put("remove", newRemoveFileTokenCommand());
+        subcommands.put("list", newListFileTokenCommand());
+    }
+
+    protected CreateFileTokenCommand newCreateFileTokenCommand() {
+        return new CreateFileTokenCommand();
+    }
+
+    protected RemoveFileTokenCommand newRemoveFileTokenCommand() {
+        return new RemoveFileTokenCommand();
+    }
+
+    protected ListFileTokenCommand newListFileTokenCommand() {
+        return new ListFileTokenCommand();
+    }
+
+    static class CreateFileTokenCommand extends EnvironmentAwareCommand {
+
+        private final OptionSpec<String> arguments;
+
+        CreateFileTokenCommand() {
+            super("Create a file token for specified service account and token name");
+            this.arguments = parser.nonOptions("service-account-principal token-name");
+        }
+
+        @Override
+        protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
+            final Tuple<String, String> tuple = parsePrincipalAndTokenName(arguments.values(options), env.settings());
+            final String principal = tuple.v1();
+            final String tokenName = tuple.v2();
+            if (false == ServiceAccountService.isServiceAccountPrincipal(principal)) {
+                throw new UserException(ExitCodes.NO_USER, "Unknown service account principal: [" + principal + "]. Must be one of ["
+                    + Strings.collectionToDelimitedString(ServiceAccountService.getServiceAccountPrincipals(), ",") + "]");
+            }
+            final Hasher hasher = Hasher.resolve(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(env.settings()));
+            final Path serviceTokensFile = FileServiceAccountsTokenStore.resolveFile(env);
+
+            FileAttributesChecker attributesChecker = new FileAttributesChecker(serviceTokensFile);
+            final Map<String, char[]> tokenHashes = new HashMap<>(FileServiceAccountsTokenStore.parseFile(serviceTokensFile, null));
+
+            try (SecureString tokenString = UUIDs.randomBase64UUIDSecureString()) {
+                final ServiceAccountToken token =
+                    new ServiceAccountToken(ServiceAccountId.fromPrincipal(principal), tokenName, tokenString);
+                if (tokenHashes.containsKey(token.getQualifiedName())) {
+                    throw new UserException(ExitCodes.CODE_ERROR, "Service token [" + token.getQualifiedName() + "] already exists");
+                }
+                tokenHashes.put(token.getQualifiedName(), hasher.hash(token.getSecret()));
+                FileServiceAccountsTokenStore.writeFile(serviceTokensFile, tokenHashes);
+                terminal.println("SERVICE_TOKEN " + token.getQualifiedName() + " = " + token.asBearerString());
+            }
+
+            attributesChecker.check(terminal);
+        }
+
+        static Tuple<String, String> parsePrincipalAndTokenName(List<String> arguments, Settings settings) throws UserException {
+            if (arguments.isEmpty()) {
+                throw new UserException(ExitCodes.USAGE, "Missing service-account-principal and token-name arguments");
+            } else if (arguments.size() == 1) {
+                throw new UserException(ExitCodes.USAGE, "Missing token-name argument");
+            } else if (arguments.size() > 2) {
+                throw new UserException(
+                    ExitCodes.USAGE,
+                    "Expected two arguments, service-account-principal and token-name, found extra: " + arguments.toString());
+            }
+            return new Tuple<>(arguments.get(0), arguments.get(1));
+        }
+    }
+
+    static class RemoveFileTokenCommand extends EnvironmentAwareCommand {
+
+        RemoveFileTokenCommand() {
+            super("Remove a file token for specified service account and token name");
+        }
+
+        @Override
+        protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
+            throw new UnsupportedOperationException("remove command not implemented yet");
+        }
+    }
+
+    static class ListFileTokenCommand extends EnvironmentAwareCommand {
+
+        ListFileTokenCommand() {
+            super("List file tokens for the specified service account");
+        }
+
+        @Override
+        protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
+            throw new UnsupportedOperationException("list command not implemented yet");
+        }
+    }
+}

+ 17 - 8
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java

@@ -28,6 +28,7 @@ import org.elasticsearch.xpack.security.authc.support.SecurityTokenType;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.util.Base64;
+import java.util.Collection;
 import java.util.Map;
 
 import static org.elasticsearch.xpack.security.authc.service.ElasticServiceAccounts.ACCOUNTS;
@@ -40,16 +41,24 @@ public class ServiceAccountService {
 
     private static final Logger logger = LogManager.getLogger(ServiceAccountService.class);
 
-    private final ServiceAccountsCredentialStore serviceAccountsCredentialStore;
+    private final ServiceAccountsTokenStore serviceAccountsTokenStore;
 
-    public ServiceAccountService(ServiceAccountsCredentialStore serviceAccountsCredentialStore) {
-        this.serviceAccountsCredentialStore = serviceAccountsCredentialStore;
+    public ServiceAccountService(ServiceAccountsTokenStore serviceAccountsTokenStore) {
+        this.serviceAccountsTokenStore = serviceAccountsTokenStore;
     }
 
     public static boolean isServiceAccount(Authentication authentication) {
         return REALM_TYPE.equals(authentication.getAuthenticatedBy().getType()) && null == authentication.getLookedUpBy();
     }
 
+    public static boolean isServiceAccountPrincipal(String principal) {
+        return ACCOUNTS.containsKey(principal);
+    }
+
+    public static Collection<String> getServiceAccountPrincipals() {
+        return ACCOUNTS.keySet();
+    }
+
     // {@link org.elasticsearch.xpack.security.authc.TokenService#extractBearerTokenFromHeader extracted} from an HTTP authorization header.
     /**
      * Parses a token object from the content of a {@link ServiceAccountToken#asBearerString()} bearer string}.
@@ -88,7 +97,7 @@ public class ServiceAccountService {
             return;
         }
 
-        final ServiceAccount account = ACCOUNTS.get(token.getAccountId().serviceName());
+        final ServiceAccount account = ACCOUNTS.get(token.getAccountId().asPrincipal());
         if (account == null) {
             final ParameterizedMessage message = new ParameterizedMessage(
                 "the [{}] service account does not exist", token.getAccountId().asPrincipal());
@@ -97,7 +106,7 @@ public class ServiceAccountService {
             return;
         }
 
-        if (serviceAccountsCredentialStore.authenticate(token)) {
+        if (serviceAccountsTokenStore.authenticate(token)) {
             listener.onResponse(success(account, token, nodeName));
         } else {
             final ParameterizedMessage message = new ParameterizedMessage(
@@ -112,11 +121,11 @@ public class ServiceAccountService {
     public void getRoleDescriptor(Authentication authentication, ActionListener<RoleDescriptor> listener) {
         assert isServiceAccount(authentication) : "authentication is not for service account: " + authentication;
 
-        final ServiceAccountId accountId = ServiceAccountId.fromPrincipal(authentication.getUser().principal());
-        final ServiceAccount account = ACCOUNTS.get(accountId.serviceName());
+        final String principal = authentication.getUser().principal();
+        final ServiceAccount account = ACCOUNTS.get(principal);
         if (account == null) {
             listener.onFailure(new ElasticsearchSecurityException(
-                "cannot load role for service account [" + accountId.asPrincipal() + "] - no such service account"
+                "cannot load role for service account [" + principal + "] - no such service account"
             ));
             return;
         }

+ 4 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java

@@ -49,6 +49,10 @@ public class ServiceAccountToken {
         return secret;
     }
 
+    public String getQualifiedName() {
+        return getAccountId().asPrincipal() + "/" + tokenName;
+    }
+
     public SecureString asBearerString() throws IOException {
         try(
             BytesStreamOutput out = new BytesStreamOutput()) {

+ 4 - 4
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsCredentialStore.java → x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java

@@ -12,18 +12,18 @@ import java.util.List;
 /**
  * The interface should be implemented by credential stores of different backends.
  */
-public interface ServiceAccountsCredentialStore {
+public interface ServiceAccountsTokenStore {
 
     /**
      * Verify the given token for encapsulated service account and credential
      */
     boolean authenticate(ServiceAccountToken token);
 
-    final class CompositeServiceAccountsCredentialStore implements ServiceAccountsCredentialStore {
+    final class CompositeServiceAccountsTokenStore implements ServiceAccountsTokenStore {
 
-        private final List<ServiceAccountsCredentialStore> stores;
+        private final List<ServiceAccountsTokenStore> stores;
 
-        public CompositeServiceAccountsCredentialStore(List<ServiceAccountsCredentialStore> stores) {
+        public CompositeServiceAccountsTokenStore(List<ServiceAccountsTokenStore> stores) {
             this.stores = stores;
         }
 

+ 33 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java

@@ -0,0 +1,33 @@
+/*
+ * 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.security.support;
+
+import org.apache.logging.log4j.util.Strings;
+import org.elasticsearch.common.CheckedBiConsumer;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+public class FileLineParser {
+    public static void parse(Path path, CheckedBiConsumer<Integer, String, IOException> lineParser) throws IOException {
+        final List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
+
+        int lineNumber = 0;
+        for (String line : lines) {
+            lineNumber++;
+            if (line.startsWith("#") || Strings.isBlank(line)) { // comment or blank
+                continue;
+            }
+
+            lineParser.accept(lineNumber, line);
+        }
+    }
+}

+ 40 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java

@@ -0,0 +1,40 @@
+/*
+ * 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.security.support;
+
+import org.elasticsearch.watcher.FileChangesListener;
+
+import java.nio.file.Path;
+
+public class FileReloadListener implements FileChangesListener {
+
+    private final Path path;
+    private final Runnable reload;
+
+    public FileReloadListener(Path path, Runnable reload) {
+        this.path = path;
+        this.reload = reload;
+    }
+
+    @Override
+    public void onFileCreated(Path file) {
+        onFileChanged(file);
+    }
+
+    @Override
+    public void onFileDeleted(Path file) {
+        onFileChanged(file);
+    }
+
+    @Override
+    public void onFileChanged(Path file) {
+        if (file.equals(this.path)) {
+            reload.run();
+        }
+    }
+}

+ 2 - 2
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java

@@ -89,7 +89,7 @@ import org.elasticsearch.xpack.security.audit.AuditUtil;
 import org.elasticsearch.xpack.security.authc.AuthenticationService.Authenticator;
 import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
 import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
-import org.elasticsearch.xpack.security.authc.service.ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore;
+import org.elasticsearch.xpack.security.authc.service.ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore;
 import org.elasticsearch.xpack.security.operator.OperatorPrivileges;
 import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
 import org.elasticsearch.xpack.security.support.SecurityIndexManager;
@@ -263,7 +263,7 @@ public class AuthenticationServiceTests extends ESTestCase {
                                           mock(CacheInvalidatorRegistry.class), threadPool);
         tokenService = new TokenService(settings, Clock.systemUTC(), client, licenseState, securityContext, securityIndex, securityIndex,
             clusterService);
-        serviceAccountService = new ServiceAccountService(new CompositeServiceAccountsCredentialStore(List.of()));
+        serviceAccountService = new ServiceAccountService(new CompositeServiceAccountsTokenStore(List.of()));
 
         operatorPrivilegesService = mock(OperatorPrivileges.OperatorPrivilegesService.class);
         service = new AuthenticationService(settings, realms, auditTrailService,

+ 6 - 6
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsCredentialStoreTests.java → x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java

@@ -15,14 +15,14 @@ import static org.hamcrest.Matchers.is;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
-public class CompositeServiceAccountsCredentialStoreTests extends ESTestCase {
+public class CompositeServiceAccountsTokenStoreTests extends ESTestCase {
 
     public void testAuthenticate() {
         final ServiceAccountToken token = mock(ServiceAccountToken.class);
 
-        final ServiceAccountsCredentialStore store1 = mock(ServiceAccountsCredentialStore.class);
-        final ServiceAccountsCredentialStore store2 = mock(ServiceAccountsCredentialStore.class);
-        final ServiceAccountsCredentialStore store3 = mock(ServiceAccountsCredentialStore.class);
+        final ServiceAccountsTokenStore store1 = mock(ServiceAccountsTokenStore.class);
+        final ServiceAccountsTokenStore store2 = mock(ServiceAccountsTokenStore.class);
+        final ServiceAccountsTokenStore store3 = mock(ServiceAccountsTokenStore.class);
 
         final boolean store1Success = randomBoolean();
         final boolean store2Success = randomBoolean();
@@ -32,8 +32,8 @@ public class CompositeServiceAccountsCredentialStoreTests extends ESTestCase {
         when(store2.authenticate(token)).thenReturn(store2Success);
         when(store3.authenticate(token)).thenReturn(store3Success);
 
-        final ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore compositeStore =
-            new ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore(List.of(store1, store2, store3));
+        final ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore compositeStore =
+            new ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore(List.of(store1, store2, store3));
 
         if (store1Success || store2Success || store3Success) {
             assertThat(compositeStore.authenticate(token), is(true));

+ 1 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java

@@ -31,7 +31,7 @@ import static org.mockito.Mockito.mock;
 public class ElasticServiceAccountsTests extends ESTestCase {
 
     public void testElasticFleetPrivileges() {
-        final Role role = Role.builder(ElasticServiceAccounts.ACCOUNTS.get("fleet").roleDescriptor(), null).build();
+        final Role role = Role.builder(ElasticServiceAccounts.ACCOUNTS.get("elastic/fleet").roleDescriptor(), null).build();
         final Authentication authentication = mock(Authentication.class);
         assertThat(role.cluster().check(CreateApiKeyAction.NAME,
             new CreateApiKeyRequest(randomAlphaOfLengthBetween(3, 8), null, null), authentication), is(true));

+ 184 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java

@@ -0,0 +1,184 @@
+/*
+ * 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.security.authc.service;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.TestEnvironment;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.TestThreadPool;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.watcher.ResourceWatcherService;
+import org.elasticsearch.xpack.core.security.audit.logfile.CapturingLogger;
+import org.elasticsearch.xpack.core.security.authc.support.Hasher;
+import org.junit.After;
+import org.junit.Before;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+public class FileServiceAccountsTokenStoreTests extends ESTestCase {
+
+    private static Map<String, String> TOKENS = Map.of(
+        "bcrypt", "46ToAwIHZWxhc3RpYwVmbGVldAZiY3J5cHQWWEU5MGVBYW9UMWlXMVctdkpmMzRxdwAAAAAAAAA",
+        "bcrypt10", "46ToAwIHZWxhc3RpYwVmbGVldAhiY3J5cHQxMBY1MmVqWGxhelJCYWZMdXpHTTVoRmNnAAAAAAAAAAAAAAAAAA",
+        "pbkdf2", "46ToAwIHZWxhc3RpYwVmbGVldAZwYmtkZjIWNURqUkNfWFJTQXFsNUhsYW1weXY3UQAAAAAAAAA",
+        "pbkdf2_50000", "46ToAwIHZWxhc3RpYwVmbGVldAxwYmtkZjJfNTAwMDAWd24wWGZ4NUlSSHkybE9LU2N2ZndyZwAAAAAAAAAAAA",
+        "pbkdf2_stretch", "46ToAwIHZWxhc3RpYwVmbGVldA5wYmtkZjJfc3RyZXRjaBZhSV8wUUxSZlJ5R0JQMVU2MFNieTJ3AAAAAAAAAA"
+    );
+
+    private Settings settings;
+    private Environment env;
+    private ThreadPool threadPool;
+
+    @Before
+    public void init() {
+        final String hashingAlgorithm = inFipsJvm() ? randomFrom("pbkdf2", "pbkdf2_50000", "pbkdf2_stretch") :
+            randomFrom("bcrypt", "bcrypt10", "pbkdf2", "pbkdf2_50000", "pbkdf2_stretch");
+        settings = Settings.builder()
+            .put("resource.reload.interval.high", "100ms")
+            .put("path.home", createTempDir())
+            .put("xpack.security.authc.service_token_hashing.algorithm", hashingAlgorithm)
+            .build();
+        env = TestEnvironment.newEnvironment(settings);
+        threadPool = new TestThreadPool("test");
+    }
+
+    @After
+    public void shutdown() {
+        terminate(threadPool);
+    }
+
+    public void testParseFile() throws Exception {
+        Path path = getDataPath("service_tokens");
+        Map<String, char[]> parsedTokenHashes = FileServiceAccountsTokenStore.parseFile(path, null);
+        assertThat(parsedTokenHashes, notNullValue());
+        assertThat(parsedTokenHashes.size(), is(5));
+
+        assertThat(new String(parsedTokenHashes.get("elastic/fleet/bcrypt")),
+            equalTo("$2a$10$uuCzGHRrEz/QMB/.bmL8qOKXHhPNt57dYBbWCH/Hbb3SjUyZ.Hf1i"));
+        assertThat(new String(parsedTokenHashes.get("elastic/fleet/bcrypt10")),
+            equalTo("$2a$10$ML0BUUxdzs8ApPNf1ayAwuh61ZhfqlzN/1DgZWZn6vNiUhpu1GKTe"));
+
+        assertThat(new String(parsedTokenHashes.get("elastic/fleet/pbkdf2")),
+            equalTo("{PBKDF2}10000$0N2h5/AsDS5uO0/A+B6y8AnTCJ3Tqo8nygbzu1gkgpo=$5aTcCtteHf2g2ye7Y3p6jSZBoGhNJ7l6F3tmUhPTwRo="));
+        assertThat(new String(parsedTokenHashes.get("elastic/fleet/pbkdf2_50000")),
+            equalTo("{PBKDF2}50000$IMzlphNClmrP/du40yxGM3fNjklg8CuACds12+Ry0jM=$KEC1S9a0NOs3OJKM4gEeBboU18EP4+3m/pyIA4MBDGk="));
+        assertThat(new String(parsedTokenHashes.get("elastic/fleet/pbkdf2_stretch")),
+            equalTo("{PBKDF2_STRETCH}10000$Pa3oNkj8xTD8j2gTgjWnTvnE6jseKApWMFjcNCLxX1U=$84ECweHFZQ2DblHEjHTRWA+fG6h5bVMyTSJUmFvTo1o="));
+
+        assertThat(parsedTokenHashes.get("elastic/fleet/plain"), nullValue());
+    }
+
+    public void testParseFileNotExists() throws IllegalAccessException, IOException {
+        Logger logger = CapturingLogger.newCapturingLogger(Level.TRACE, null);
+        final Map<String, char[]> tokenHashes =
+            FileServiceAccountsTokenStore.parseFile(getDataPath("service_tokens").getParent().resolve("does-not-exist"), logger);
+        assertThat(tokenHashes.isEmpty(), is(true));
+        final List<String> events = CapturingLogger.output(logger.getName(), Level.TRACE);
+        assertThat(events.size(), equalTo(2));
+        assertThat(events.get(1), containsString("does not exist"));
+    }
+
+    public void testAutoReload() throws Exception {
+        Path serviceTokensSourceFile = getDataPath("service_tokens");
+        Path configDir = env.configFile();
+        Files.createDirectories(configDir);
+        Path targetFile = configDir.resolve("service_tokens");
+        Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
+        final Hasher hasher = Hasher.resolve(settings.get("xpack.security.authc.service_token_hashing.algorithm"));
+        try (ResourceWatcherService watcherService = new ResourceWatcherService(settings, threadPool)) {
+            final CountDownLatch latch = new CountDownLatch(5);
+
+            FileServiceAccountsTokenStore store = new FileServiceAccountsTokenStore(env, watcherService);
+            store.addListener(latch::countDown);
+            //Token name shares the hashing algorithm name for convenience
+            String tokenName = settings.get("xpack.security.authc.service_token_hashing.algorithm");
+            final String qualifiedTokenName = "elastic/fleet/" + tokenName;
+            assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true));
+
+            // A blank line should not trigger update
+            try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) {
+                writer.append("\n");
+            }
+            watcherService.notifyNow(ResourceWatcherService.Frequency.HIGH);
+            if (latch.getCount() != 5) {
+                fail("Listener should not be called as service tokens are not changed.");
+            }
+            assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true));
+
+            // Add a new entry
+            final char[] newTokenHash =
+                hasher.hash(new SecureString("46ToAwIHZWxhc3RpYwVmbGVldAZ0b2tlbjEWWkYtQ3dlWlVTZldJX3p5Vk9ySnlSQQAAAAAAAAA".toCharArray()));
+            try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) {
+                writer.newLine();
+                writer.append("elastic/fleet/token1:").append(new String(newTokenHash));
+            }
+            assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 4, latch.getCount()),
+                5, TimeUnit.SECONDS);
+            assertThat(store.getTokenHashes().containsKey("elastic/fleet/token1"), is(true));
+
+            // Remove the new entry
+            Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
+            assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 3, latch.getCount()),
+                5, TimeUnit.SECONDS);
+            assertThat(store.getTokenHashes().containsKey("elastic/fleet/token1"), is(false));
+            assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true));
+
+            // Write a mal-formatted line
+            if (randomBoolean()) {
+                try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) {
+                    writer.newLine();
+                    writer.append("elastic/fleet/tokenxfoobar");
+                }
+            } else {
+                // writing in utf_16 should cause a parsing error as we try to read the file in utf_8
+                try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_16, StandardOpenOption.APPEND)) {
+                    writer.newLine();
+                    writer.append("elastic/fleet/tokenx:").append(new String(newTokenHash));
+                }
+            }
+            assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 2, latch.getCount()),
+                5, TimeUnit.SECONDS);
+            assertThat(store.getTokenHashes().isEmpty(), is(true));
+
+            // Restore to original file again
+            Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
+            assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 1, latch.getCount()),
+                5, TimeUnit.SECONDS);
+            assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true));
+
+            // Duplicate entry
+            try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) {
+                writer.newLine();
+                writer.append(qualifiedTokenName + ":").append(new String(newTokenHash));
+            }
+            assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 0, latch.getCount()),
+                5, TimeUnit.SECONDS);
+            assertThat(store.getTokenHashes().get(qualifiedTokenName), equalTo(newTokenHash));
+        }
+    }
+}

+ 10 - 5
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java

@@ -26,6 +26,7 @@ import org.junit.Before;
 import java.io.IOException;
 import java.util.Base64;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
 import static org.hamcrest.Matchers.containsString;
@@ -37,14 +38,14 @@ import static org.mockito.Mockito.when;
 public class ServiceAccountServiceTests extends ESTestCase {
 
     private ThreadContext threadContext;
-    private ServiceAccountsCredentialStore serviceAccountsCredentialStore;
+    private ServiceAccountsTokenStore serviceAccountsTokenStore;
     private ServiceAccountService serviceAccountService;
 
     @Before
     public void init() {
         threadContext = new ThreadContext(Settings.EMPTY);
-        serviceAccountsCredentialStore = mock(ServiceAccountsCredentialStore.class);
-        serviceAccountService = new ServiceAccountService(serviceAccountsCredentialStore);
+        serviceAccountsTokenStore = mock(ServiceAccountsTokenStore.class);
+        serviceAccountService = new ServiceAccountService(serviceAccountsTokenStore);
     }
 
     public void testIsServiceAccount() {
@@ -69,6 +70,10 @@ public class ServiceAccountServiceTests extends ESTestCase {
         }
     }
 
+    public void testGetServiceAccountPrincipals() {
+        assertThat(ServiceAccountService.getServiceAccountPrincipals(), equalTo(Set.of("elastic/fleet")));
+    }
+
     public void testTryParseToken() throws IOException {
         // Null for null
         assertNull(ServiceAccountService.tryParseToken(null));
@@ -146,8 +151,8 @@ public class ServiceAccountServiceTests extends ESTestCase {
         final ServiceAccountToken token4 = new ServiceAccountToken(accountId3, randomAlphaOfLengthBetween(3, 8),
             new SecureString(randomAlphaOfLength(20).toCharArray()));
         final String nodeName = randomAlphaOfLengthBetween(3, 8);
-        when(serviceAccountsCredentialStore.authenticate(token3)).thenReturn(true);
-        when(serviceAccountsCredentialStore.authenticate(token4)).thenReturn(false);
+        when(serviceAccountsTokenStore.authenticate(token3)).thenReturn(true);
+        when(serviceAccountsTokenStore.authenticate(token4)).thenReturn(false);
 
         final PlainActionFuture<Authentication> future3 = new PlainActionFuture<>();
         serviceAccountService.authenticateWithToken(token3, threadContext, nodeName, future3);

+ 39 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java

@@ -0,0 +1,39 @@
+/*
+ * 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.security.support;
+
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+public class FileLineParserTests extends ESTestCase {
+
+    public void testParse() throws IOException {
+        Path path = getDataPath("../authc/support/role_mapping.yml");
+
+        final Map<Integer, String> lines = new HashMap<>(Map.of(
+            7, "security:",
+            8, "  - \"cn=avengers,ou=marvel,o=superheros\"",
+            9, "  - \"cn=shield,ou=marvel,o=superheros\"",
+            10, "avenger:",
+            11, "  - \"cn=avengers,ou=marvel,o=superheros\"",
+            12, "  - \"cn=Horatio Hornblower,ou=people,o=sevenSeas\""
+        ));
+
+        FileLineParser.parse(path, (lineNumber, line) -> {
+            assertThat(lines.remove(lineNumber), equalTo(line));
+        });
+        assertThat(lines.isEmpty(), is(true));
+    }
+}

+ 37 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java

@@ -0,0 +1,37 @@
+/*
+ * 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.security.support;
+
+import org.elasticsearch.common.io.PathUtils;
+import org.elasticsearch.test.ESTestCase;
+
+import java.nio.file.Path;
+import java.util.concurrent.CountDownLatch;
+import java.util.function.Consumer;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class FileReloadListenerTests extends ESTestCase {
+
+    public void testCallback() {
+        final CountDownLatch latch = new CountDownLatch(2);
+        final FileReloadListener fileReloadListener = new FileReloadListener(PathUtils.get("foo", "bar"), latch::countDown);
+
+        Consumer<Path> consumer =
+            randomFrom(fileReloadListener::onFileCreated, fileReloadListener::onFileChanged, fileReloadListener::onFileDeleted);
+
+        consumer.accept(PathUtils.get("foo", "bar"));
+        assertThat(latch.getCount(), equalTo(1L));
+
+        consumer.accept(PathUtils.get("fizz", "baz"));
+        assertThat(latch.getCount(), equalTo(1L));
+
+        consumer.accept(PathUtils.get("foo", "bar"));
+        assertThat(latch.getCount(), equalTo(0L));
+    }
+}

+ 6 - 0
x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens

@@ -0,0 +1,6 @@
+elastic/fleet/pbkdf2:{PBKDF2}10000$0N2h5/AsDS5uO0/A+B6y8AnTCJ3Tqo8nygbzu1gkgpo=$5aTcCtteHf2g2ye7Y3p6jSZBoGhNJ7l6F3tmUhPTwRo=
+elastic/fleet/bcrypt10:$2a$10$ML0BUUxdzs8ApPNf1ayAwuh61ZhfqlzN/1DgZWZn6vNiUhpu1GKTe
+elastic/fleet/pbkdf2_stretch:{PBKDF2_STRETCH}10000$Pa3oNkj8xTD8j2gTgjWnTvnE6jseKApWMFjcNCLxX1U=$84ECweHFZQ2DblHEjHTRWA+fG6h5bVMyTSJUmFvTo1o=
+elastic/fleet/pbkdf2_50000:{PBKDF2}50000$IMzlphNClmrP/du40yxGM3fNjklg8CuACds12+Ry0jM=$KEC1S9a0NOs3OJKM4gEeBboU18EP4+3m/pyIA4MBDGk=
+elastic/fleet/bcrypt:$2a$10$uuCzGHRrEz/QMB/.bmL8qOKXHhPNt57dYBbWCH/Hbb3SjUyZ.Hf1i
+elastic/fleet/plain:{plain}_By842iQQVKSCLxVcJZWvw

+ 173 - 0
x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java

@@ -0,0 +1,173 @@
+/*
+ * 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.security.authc.service;
+
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import org.elasticsearch.cli.Command;
+import org.elasticsearch.cli.CommandTestCase;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.UUIDs;
+import org.elasticsearch.common.collect.Tuple;
+import org.elasticsearch.common.io.PathUtilsForTesting;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.internal.io.IOUtils;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.xpack.core.security.authc.support.Hasher;
+import org.elasticsearch.xpack.security.authc.service.FileTokensTool.CreateFileTokenCommand;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.elasticsearch.test.SecurityIntegTestCase.getFastStoredHashAlgoForTests;
+import static org.hamcrest.Matchers.containsString;
+
+public class FileTokensToolTests extends CommandTestCase {
+
+    // the mock filesystem we use so permissions/users/groups can be modified
+    static FileSystem jimfs;
+    String pathHomeParameter;
+
+    // the config dir for each test to use
+    Path confDir;
+
+    // settings used to create an Environment for tools
+    Settings settings;
+
+    Hasher hasher;
+    private final SecureString token1 = UUIDs.randomBase64UUIDSecureString();
+    private final SecureString token2 = UUIDs.randomBase64UUIDSecureString();
+    private final SecureString token3 = UUIDs.randomBase64UUIDSecureString();
+
+    @BeforeClass
+    public static void setupJimfs() throws IOException {
+        String view = randomFrom("basic", "posix");
+        Configuration conf = Configuration.unix().toBuilder().setAttributeViews(view).build();
+        jimfs = Jimfs.newFileSystem(conf);
+        PathUtilsForTesting.installMock(jimfs);
+    }
+
+    @Before
+    public void setupHome() throws IOException {
+        Path homeDir = jimfs.getPath("eshome");
+        IOUtils.rm(homeDir);
+        confDir = homeDir.resolve("config");
+        Files.createDirectories(confDir);
+        hasher = getFastStoredHashAlgoForTests();
+
+        Files.write(confDir.resolve("service_tokens"), List.of(
+            "elastic/fleet/server_1:" + new String(hasher.hash(token1)),
+            "elastic/fleet/server_2:" + new String(hasher.hash(token2)),
+            "elastic/fleet/server_3:" + new String(hasher.hash(token3))
+        ));
+        settings = Settings.builder()
+            .put("path.home", homeDir)
+            .put("xpack.security.authc.service_token_hashing.algorithm", hasher.name())
+            .build();
+        pathHomeParameter = "-Epath.home=" + homeDir;
+    }
+
+    @AfterClass
+    public static void closeJimfs() throws IOException {
+        if (jimfs != null) {
+            jimfs.close();
+            jimfs = null;
+        }
+    }
+
+    @Override
+    protected Command newCommand() {
+        return new FileTokensTool() {
+            @Override
+            protected CreateFileTokenCommand newCreateFileTokenCommand() {
+                return new CreateFileTokenCommand() {
+                    @Override
+                    protected Environment createEnv(Map<String, String> settings) throws UserException {
+                        return new Environment(FileTokensToolTests.this.settings, confDir);
+                    }
+                };
+            }
+        };
+    }
+
+    public void testParsePrincipalAndTokenName() throws UserException {
+        final String tokenName1 = randomAlphaOfLengthBetween(3, 8);
+        final Tuple<String, String> tuple1 =
+            CreateFileTokenCommand.parsePrincipalAndTokenName(List.of("elastic/fleet", tokenName1), Settings.EMPTY);
+        assertEquals("elastic/fleet", tuple1.v1());
+        assertEquals(tokenName1, tuple1.v2());
+
+        final UserException e2 = expectThrows(UserException.class,
+            () -> CreateFileTokenCommand.parsePrincipalAndTokenName(List.of(randomAlphaOfLengthBetween(6, 16)), Settings.EMPTY));
+        assertThat(e2.getMessage(), containsString("Missing token-name argument"));
+
+        final UserException e3 = expectThrows(UserException.class,
+            () -> CreateFileTokenCommand.parsePrincipalAndTokenName(List.of(), Settings.EMPTY));
+        assertThat(e3.getMessage(), containsString("Missing service-account-principal and token-name arguments"));
+
+        final UserException e4 = expectThrows(UserException.class,
+            () -> CreateFileTokenCommand.parsePrincipalAndTokenName(
+                List.of(randomAlphaOfLengthBetween(6, 16), randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)),
+                Settings.EMPTY));
+        assertThat(e4.getMessage(), containsString(
+            "Expected two arguments, service-account-principal and token-name, found extra:"));
+    }
+
+    public void testCreateToken() throws Exception {
+        execute("create", pathHomeParameter, "elastic/fleet", "server_42");
+        assertServiceTokenExists("elastic/fleet/server_42");
+        execute("create", pathHomeParameter, "elastic/fleet", "server_43");
+        assertServiceTokenExists("elastic/fleet/server_43");
+        final String output = terminal.getOutput();
+        assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/server_42 = "));
+        assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/server_43 = "));
+    }
+
+    public void testCreateTokenWithInvalidServiceAccount() throws Exception {
+        final UserException e = expectThrows(UserException.class,
+            () -> execute("create", pathHomeParameter,
+                randomFrom("elastic/foo", "foo/fleet", randomAlphaOfLengthBetween(6, 16)),
+                randomAlphaOfLengthBetween(3, 8)));
+        assertThat(e.getMessage(), containsString("Unknown service account principal: "));
+        assertThat(e.getMessage(), containsString("Must be one of "));
+    }
+
+    private void assertServiceTokenExists(String key) throws IOException {
+        List<String> lines = Files.readAllLines(confDir.resolve("service_tokens"), StandardCharsets.UTF_8);
+        for (String line : lines) {
+            String[] keyHash = line.split(":", 2);
+            if (keyHash.length != 2) {
+                fail("Corrupted service_tokens file, line: " + line);
+            }
+            if (key.equals(keyHash[0])) {
+                return;
+            }
+        }
+        fail("Could not find key " + key + " in service_tokens file:\n" + lines.toString());
+    }
+
+    private void assertServiceTokenNotExists(String key) throws IOException {
+        List<String> lines = Files.readAllLines(confDir.resolve("service_tokens"), StandardCharsets.UTF_8);
+        for (String line : lines) {
+            String[] keyHash = line.split(":", 2);
+            if (keyHash.length != 2) {
+                fail("Corrupted service_tokens file, line: " + line);
+            }
+            assertNotEquals(key, keyHash[0]);
+        }
+    }
+}