diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java b/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java index 259a58dd0a3c6..b098b5f7403d4 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java @@ -433,6 +433,8 @@ public void test94ElasticsearchNodeExecuteCliNotEsHomeWorkDir() throws Exception 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); diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java index bd18faa4bbd74..f91c42d72ab6b 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java @@ -198,6 +198,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist "elasticsearch-sql-cli", "elasticsearch-syskeygen", "elasticsearch-users", + "elasticsearch-service-tokens", "x-pack-env", "x-pack-security-env", "x-pack-watcher-env" diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java index a9ae71d06371f..d9691f9ac6397 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java @@ -501,6 +501,7 @@ private static void verifyDefaultInstallation(Installation es) { "elasticsearch-sql-cli", "elasticsearch-syskeygen", "elasticsearch-users", + "elasticsearch-service-tokens", "x-pack-env", "x-pack-security-env", "x-pack-watcher-env" diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java index 928e0b9f74d81..e8e6f1061ac0f 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java @@ -189,5 +189,6 @@ public class Executables { 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"); } } diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java index 00de907ce53d7..9a68714228c1e 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java @@ -220,6 +220,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist "elasticsearch-sql-cli", "elasticsearch-syskeygen", "elasticsearch-users", + "elasticsearch-service-tokens", "x-pack-env", "x-pack-security-env", "x-pack-watcher-env" diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java index 78908fababa28..ec83fc2cc4148 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java @@ -162,6 +162,26 @@ private 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 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 DEFAULT_SUPPORTED_PROTOCOLS; static { diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java index 1d7b22d1ee792..3b7713fff3127 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java @@ -76,6 +76,27 @@ public void testDefaultSupportedProtocols() { } } + 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); diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens b/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens new file mode 100755 index 0000000000000..c9cdbd78bc8f0 --- /dev/null +++ b/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 \ + "$@" diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat b/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat new file mode 100644 index 0000000000000..6ca1260c2a6ab --- /dev/null +++ b/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% diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index e610bacd69279..dca1547f15890 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -201,7 +201,7 @@ 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 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste 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, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java index 7aad0fcea978e..873bf021dff77 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java @@ -16,7 +16,6 @@ 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.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 FileUserPasswdStore(RealmConfig config, ResourceWatcherService watcherSer 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 @@ void notifyRefresh() { 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 previousUsers = users; - users = parseFileLenient(file, logger, settings); + private void tryReload() { + final Map 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(); } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java index e6ad78ffcbc7b..b96265aac2824 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java +++ b/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 ACCOUNTS = List.of(FLEET_ACCOUNT).stream() - .collect(Collectors.toMap(a -> a.id().serviceName(), Function.identity()));; + static final Map ACCOUNTS = List.of(FLEET_ACCOUNT).stream() + .collect(Collectors.toMap(a -> a.id().asPrincipal(), Function.identity()));; private ElasticServiceAccounts() {} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java new file mode 100644 index 0000000000000..647d6b6f17e7d --- /dev/null +++ b/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 listeners; + private volatile Map 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 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 getTokenHashes() { + return tokenHashes; + } + + static Path resolveFile(Environment env) { + return XPackPlugin.resolveConfigFile(env, "service_tokens"); + } + + static Map 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 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 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 tokenHashes) { + SecurityFiles.writeFileAtomically( + path, tokenHashes, e -> String.format(Locale.ROOT, "%s:%s", e.getKey(), new String(e.getValue()))); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java new file mode 100644 index 0000000000000..8dff080b7cb50 --- /dev/null +++ b/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 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 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 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 parsePrincipalAndTokenName(List 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"); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java index cf34ec7acd600..56639b8f87b67 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java @@ -28,6 +28,7 @@ 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 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 void authenticateWithToken(ServiceAccountToken token, ThreadContext threa 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 void authenticateWithToken(ServiceAccountToken token, ThreadContext threa 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 void authenticateWithToken(ServiceAccountToken token, ThreadContext threa public void getRoleDescriptor(Authentication authentication, ActionListener 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; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java index e84d4c377e285..6ff8981f3a7ea 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java @@ -49,6 +49,10 @@ public SecureString getSecret() { return secret; } + public String getQualifiedName() { + return getAccountId().asPrincipal() + "/" + tokenName; + } + public SecureString asBearerString() throws IOException { try( BytesStreamOutput out = new BytesStreamOutput()) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsCredentialStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java similarity index 71% rename from x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsCredentialStore.java rename to x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java index fc39510cfde59..13e210c8c9ec7 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsCredentialStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java @@ -12,18 +12,18 @@ /** * 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 stores; + private final List stores; - public CompositeServiceAccountsCredentialStore(List stores) { + public CompositeServiceAccountsTokenStore(List stores) { this.stores = stores; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java new file mode 100644 index 0000000000000..8fd03a6d4cb82 --- /dev/null +++ b/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 lineParser) throws IOException { + final List 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); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java new file mode 100644 index 0000000000000..560178e0c1349 --- /dev/null +++ b/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(); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 531789e4c7cb0..875d34aa506f9 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -89,7 +89,7 @@ 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 void init() throws Exception { 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, diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsCredentialStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java similarity index 67% rename from x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsCredentialStoreTests.java rename to x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java index f77b2ca6fe52e..788e766782a71 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsCredentialStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java @@ -15,14 +15,14 @@ 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 void testAuthenticate() { 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)); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java index d1274bff17c46..60ab161643354 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java @@ -31,7 +31,7 @@ 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)); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java new file mode 100644 index 0000000000000..3c11521a95583 --- /dev/null +++ b/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 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 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 tokenHashes = + FileServiceAccountsTokenStore.parseFile(getDataPath("service_tokens").getParent().resolve("does-not-exist"), logger); + assertThat(tokenHashes.isEmpty(), is(true)); + final List 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)); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java index 916a32f398331..3e8acd48c0583 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java @@ -26,6 +26,7 @@ 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 @@ 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 void testIsServiceAccount() { } } + 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 void testAuthenticateWithToken() throws ExecutionException, InterruptedEx 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 future3 = new PlainActionFuture<>(); serviceAccountService.authenticateWithToken(token3, threadContext, nodeName, future3); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java new file mode 100644 index 0000000000000..75932992d503d --- /dev/null +++ b/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 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)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java new file mode 100644 index 0000000000000..9acf561437225 --- /dev/null +++ b/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 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)); + } +} diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens new file mode 100644 index 0000000000000..56b028dd4a0aa --- /dev/null +++ b/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 diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java new file mode 100644 index 0000000000000..4bd670912c3da --- /dev/null +++ b/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 settings) throws UserException { + return new Environment(FileTokensToolTests.this.settings, confDir); + } + }; + } + }; + } + + public void testParsePrincipalAndTokenName() throws UserException { + final String tokenName1 = randomAlphaOfLengthBetween(3, 8); + final Tuple 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 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 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]); + } + } +}