From ccee1b1dead6d2cb39a270b1cd5bfea874c2b8cc Mon Sep 17 00:00:00 2001 From: Przemyslaw Motacki Date: Mon, 24 Jun 2024 16:33:46 +0200 Subject: [PATCH] SNOW-1454054 - Read connection configuration from file. (#1780) * SNOW-1454054 - Read connection configuration from file. --- parent-pom.xml | 4 + .../client/config/ConnectionParameters.java | 26 +++ .../config/SFConnectionConfigParser.java | 149 ++++++++++++++++++ .../client/jdbc/SnowflakeDriver.java | 49 +++++- .../client/jdbc/SnowflakeSQLException.java | 4 + .../net/snowflake/client/RunningNotOnWin.java | 9 ++ .../config/SFConnectionConfigParserTest.java | 133 ++++++++++++++++ .../FileConnectionConfigurationLatestIT.java | 52 ++++++ thin_public_pom.xml | 4 + 9 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 src/main/java/net/snowflake/client/config/ConnectionParameters.java create mode 100644 src/main/java/net/snowflake/client/config/SFConnectionConfigParser.java create mode 100644 src/test/java/net/snowflake/client/RunningNotOnWin.java create mode 100644 src/test/java/net/snowflake/client/config/SFConnectionConfigParserTest.java create mode 100644 src/test/java/net/snowflake/client/jdbc/FileConnectionConfigurationLatestIT.java diff --git a/parent-pom.xml b/parent-pom.xml index 1c5ab3c2f..8642fe429 100644 --- a/parent-pom.xml +++ b/parent-pom.xml @@ -528,6 +528,10 @@ com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.dataformat + jackson-dataformat-toml + com.google.api gax diff --git a/src/main/java/net/snowflake/client/config/ConnectionParameters.java b/src/main/java/net/snowflake/client/config/ConnectionParameters.java new file mode 100644 index 000000000..5fa97ac91 --- /dev/null +++ b/src/main/java/net/snowflake/client/config/ConnectionParameters.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2012-2024 Snowflake Computing Inc. All rights reserved. + */ +package net.snowflake.client.config; + +import java.util.Properties; +import net.snowflake.client.core.SnowflakeJdbcInternalApi; + +@SnowflakeJdbcInternalApi +public class ConnectionParameters { + private final String url; + private final Properties params; + + public ConnectionParameters(String uri, Properties params) { + this.url = uri; + this.params = params; + } + + public String getUrl() { + return url; + } + + public Properties getParams() { + return params; + } +} diff --git a/src/main/java/net/snowflake/client/config/SFConnectionConfigParser.java b/src/main/java/net/snowflake/client/config/SFConnectionConfigParser.java new file mode 100644 index 000000000..9040fa392 --- /dev/null +++ b/src/main/java/net/snowflake/client/config/SFConnectionConfigParser.java @@ -0,0 +1,149 @@ +package net.snowflake.client.config; + +import static net.snowflake.client.jdbc.SnowflakeUtil.systemGetEnv; + +import com.fasterxml.jackson.dataformat.toml.TomlMapper; +import com.google.common.base.Strings; +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import net.snowflake.client.core.Constants; +import net.snowflake.client.core.SnowflakeJdbcInternalApi; +import net.snowflake.client.jdbc.SnowflakeSQLException; +import net.snowflake.client.log.SFLogger; +import net.snowflake.client.log.SFLoggerFactory; + +@SnowflakeJdbcInternalApi +public class SFConnectionConfigParser { + + private static final SFLogger logger = SFLoggerFactory.getLogger(SFConnectionConfigParser.class); + private static final TomlMapper mapper = new TomlMapper(); + public static final String SNOWFLAKE_HOME_KEY = "SNOWFLAKE_HOME"; + public static final String SNOWFLAKE_DIR = ".snowflake"; + public static final String SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY = + "SNOWFLAKE_DEFAULT_CONNECTION_NAME"; + public static final String DEFAULT = "default"; + public static final String SNOWFLAKE_TOKEN_FILE_PATH = "/snowflake/session/token"; + + private static Map loadDefaultConnectionConfiguration( + String defaultConnectionName) throws SnowflakeSQLException { + String configDirectory = + Optional.ofNullable(systemGetEnv(SNOWFLAKE_HOME_KEY)) + .orElse(Paths.get(System.getProperty("user.home"), SNOWFLAKE_DIR).toString()); + Path configFilePath = Paths.get(configDirectory, "connections.toml"); + + if (Files.exists(configFilePath)) { + logger.debug( + "Reading connection parameters from file using key: {} []", + configFilePath, + defaultConnectionName); + Map parametersMap = readParametersMap(configFilePath); + Map defaultConnectionParametersMap = parametersMap.get(defaultConnectionName); + return defaultConnectionParametersMap; + } else { + logger.debug("Connection configuration file does not exist"); + return new HashMap<>(); + } + } + + private static Map readParametersMap(Path configFilePath) + throws SnowflakeSQLException { + try { + File file = new File(configFilePath.toUri()); + varifyFilePermissionSecure(configFilePath); + return mapper.readValue(file, Map.class); + } catch (IOException ex) { + throw new SnowflakeSQLException(ex, "Problem during reading a configuration file."); + } + } + + private static void varifyFilePermissionSecure(Path configFilePath) + throws IOException, SnowflakeSQLException { + if (Constants.getOS() != Constants.OS.WINDOWS) { + PosixFileAttributeView posixFileAttributeView = + Files.getFileAttributeView(configFilePath, PosixFileAttributeView.class); + if (!posixFileAttributeView.readAttributes().permissions().stream() + .allMatch( + o -> + Arrays.asList(PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_READ) + .contains(o))) { + logger.error( + "Reading from file {} is not safe because of insufficient permissions", configFilePath); + throw new SnowflakeSQLException( + String.format( + "Reading from file %s is not safe because of insufficient permissions", + configFilePath)); + } + } + } + + public static ConnectionParameters buildConnectionParameters() throws SnowflakeSQLException { + String defaultConnectionName = + Optional.ofNullable(systemGetEnv(SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY)).orElse(DEFAULT); + Map fileConnectionConfiguration = + loadDefaultConnectionConfiguration(defaultConnectionName); + + if (fileConnectionConfiguration != null && !fileConnectionConfiguration.isEmpty()) { + Properties conectionProperties = new Properties(); + conectionProperties.putAll(fileConnectionConfiguration); + + String url = + Optional.ofNullable(fileConnectionConfiguration.get("account")) + .map(ac -> createUrl(ac, fileConnectionConfiguration)) + .orElse(null); + logger.debug("Url created using parameters from connection configuration file: {}", url); + + if ("oauth".equals(fileConnectionConfiguration.get("authenticator")) + && fileConnectionConfiguration.get("token") == null) { + Path path = + Paths.get( + Optional.ofNullable(fileConnectionConfiguration.get("token_file_path")) + .orElse(SNOWFLAKE_TOKEN_FILE_PATH)); + logger.debug("Token used in connect is read from file: {}", path); + try { + String token = new String(Files.readAllBytes(path), Charset.defaultCharset()); + if (!token.isEmpty()) { + putPropertyIfNotNull(conectionProperties, "token", token.trim()); + } else { + logger.warn("The token has empty value"); + } + } catch (IOException ex) { + throw new SnowflakeSQLException(ex, "There is a problem during reading token from file"); + } + } + return new ConnectionParameters(url, conectionProperties); + } else { + return null; + } + } + + private static String createUrl(String account, Map fileConnectionConfiguration) { + String host = String.format("%s.snowflakecomputing.com", account); + String port = fileConnectionConfiguration.get("port"); + String protocol = fileConnectionConfiguration.get("protocol"); + if (Strings.isNullOrEmpty(port)) { + if ("https".equals(protocol)) { + port = "443"; + } else { + port = "80"; + } + } + return String.format("jdbc:snowflake://%s:%s", host, port); + } + + private static void putPropertyIfNotNull(Properties props, Object key, Object value) { + if (key != null && value != null) { + props.put(key, value); + } + } +} diff --git a/src/main/java/net/snowflake/client/jdbc/SnowflakeDriver.java b/src/main/java/net/snowflake/client/jdbc/SnowflakeDriver.java index 6baba4a57..73f201ac2 100644 --- a/src/main/java/net/snowflake/client/jdbc/SnowflakeDriver.java +++ b/src/main/java/net/snowflake/client/jdbc/SnowflakeDriver.java @@ -14,7 +14,12 @@ import java.sql.SQLFeatureNotSupportedException; import java.util.List; import java.util.Properties; +import net.snowflake.client.config.ConnectionParameters; +import net.snowflake.client.config.SFConnectionConfigParser; import net.snowflake.client.core.SecurityUtil; +import net.snowflake.client.core.SnowflakeJdbcInternalApi; +import net.snowflake.client.log.SFLogger; +import net.snowflake.client.log.SFLoggerFactory; import net.snowflake.common.core.ResourceBundleManager; import net.snowflake.common.core.SqlState; @@ -26,6 +31,8 @@ * loading */ public class SnowflakeDriver implements Driver { + private static final SFLogger logger = SFLoggerFactory.getLogger(SnowflakeDriver.class); + public static final String AUTO_CONNECTION_STRING_PREFIX = "jdbc:snowflake:auto"; static SnowflakeDriver INSTANCE; public static final Properties EMPTY_PROPERTIES = new Properties(); @@ -200,18 +207,52 @@ public boolean acceptsURL(String url) { */ @Override public Connection connect(String url, Properties info) throws SQLException { - if (url == null) { + ConnectionParameters connectionParameters = + overrideByFileConnectionParametersIfAutoConfiguration(url, info); + + if (connectionParameters.getUrl() == null) { // expected return format per the JDBC spec for java.sql.Driver#connect() throw new SnowflakeSQLException("Unable to connect to url of 'null'."); } - if (!SnowflakeConnectString.hasSupportedPrefix(url)) { + if (!SnowflakeConnectString.hasSupportedPrefix(connectionParameters.getUrl())) { return null; // expected return format per the JDBC spec for java.sql.Driver#connect() } - SnowflakeConnectString conStr = SnowflakeConnectString.parse(url, info); + SnowflakeConnectString conStr = + SnowflakeConnectString.parse( + connectionParameters.getUrl(), connectionParameters.getParams()); if (!conStr.isValid()) { throw new SnowflakeSQLException("Connection string is invalid. Unable to parse."); } - return new SnowflakeConnectionV1(url, info); + return new SnowflakeConnectionV1( + connectionParameters.getUrl(), connectionParameters.getParams()); + } + + private static ConnectionParameters overrideByFileConnectionParametersIfAutoConfiguration( + String url, Properties info) throws SnowflakeSQLException { + if (url != null && url.contains(AUTO_CONNECTION_STRING_PREFIX)) { + // Connect using connection configuration file + ConnectionParameters connectionParameters = + SFConnectionConfigParser.buildConnectionParameters(); + if (connectionParameters == null) { + throw new SnowflakeSQLException( + "Unavailable connection configuration parameters expected for auto configuration using file"); + } + return connectionParameters; + } else { + return new ConnectionParameters(url, info); + } + } + + /** + * Connect method using connection configuration file + * + * @return connection + * @throws SQLException if failed to create a snowflake connection + */ + @SnowflakeJdbcInternalApi + public Connection connect() throws SQLException { + logger.debug("Execute internal method connect() without parameters"); + return connect(AUTO_CONNECTION_STRING_PREFIX, null); } @Override diff --git a/src/main/java/net/snowflake/client/jdbc/SnowflakeSQLException.java b/src/main/java/net/snowflake/client/jdbc/SnowflakeSQLException.java index 660e83134..a88829ec6 100644 --- a/src/main/java/net/snowflake/client/jdbc/SnowflakeSQLException.java +++ b/src/main/java/net/snowflake/client/jdbc/SnowflakeSQLException.java @@ -172,6 +172,10 @@ public SnowflakeSQLException(String reason) { super(reason); } + public SnowflakeSQLException(Throwable ex, String message) { + super(message, ex); + } + public String getQueryId() { return queryId; } diff --git a/src/test/java/net/snowflake/client/RunningNotOnWin.java b/src/test/java/net/snowflake/client/RunningNotOnWin.java new file mode 100644 index 000000000..ce5cdf7d1 --- /dev/null +++ b/src/test/java/net/snowflake/client/RunningNotOnWin.java @@ -0,0 +1,9 @@ +package net.snowflake.client; + +import net.snowflake.client.core.Constants; + +public class RunningNotOnWin implements ConditionalIgnoreRule.IgnoreCondition { + public boolean isSatisfied() { + return Constants.getOS() != Constants.OS.WINDOWS; + } +} diff --git a/src/test/java/net/snowflake/client/config/SFConnectionConfigParserTest.java b/src/test/java/net/snowflake/client/config/SFConnectionConfigParserTest.java new file mode 100644 index 000000000..e68e68fa0 --- /dev/null +++ b/src/test/java/net/snowflake/client/config/SFConnectionConfigParserTest.java @@ -0,0 +1,133 @@ +package net.snowflake.client.config; + +import static net.snowflake.client.config.SFConnectionConfigParser.SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY; +import static net.snowflake.client.config.SFConnectionConfigParser.SNOWFLAKE_HOME_KEY; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeFalse; + +import com.fasterxml.jackson.dataformat.toml.TomlMapper; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import net.snowflake.client.RunningNotOnLinuxMac; +import net.snowflake.client.core.Constants; +import net.snowflake.client.jdbc.SnowflakeSQLException; +import net.snowflake.client.jdbc.SnowflakeUtil; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SFConnectionConfigParserTest { + + private Path tempPath = null; + private TomlMapper tomlMapper = new TomlMapper(); + + @Before + public void setUp() throws IOException { + tempPath = Files.createTempDirectory(".snowflake"); + } + + @After + public void close() throws IOException { + SnowflakeUtil.systemUnsetEnv(SNOWFLAKE_HOME_KEY); + SnowflakeUtil.systemUnsetEnv(SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY); + Files.walk(tempPath).map(Path::toFile).forEach(File::delete); + Files.delete(tempPath); + } + + @Test + public void testLoadSFConnectionConfigWrongConfigurationName() + throws SnowflakeSQLException, IOException { + SnowflakeUtil.systemSetEnv(SNOWFLAKE_HOME_KEY, tempPath.toString()); + SnowflakeUtil.systemSetEnv(SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY, "unknown"); + prepareConnectionConfigurationTomlFile(null, true); + ConnectionParameters connectionParameters = + SFConnectionConfigParser.buildConnectionParameters(); + assertNull(connectionParameters); + } + + @Test + public void testLoadSFConnectionConfigInValidPath() throws SnowflakeSQLException, IOException { + SnowflakeUtil.systemSetEnv(SNOWFLAKE_HOME_KEY, Paths.get("unknownPath").toString()); + prepareConnectionConfigurationTomlFile(null, true); + assertNull(SFConnectionConfigParser.buildConnectionParameters()); + } + + @Test + public void testLoadSFConnectionConfigWithTokenFromFile() + throws SnowflakeSQLException, IOException { + SnowflakeUtil.systemSetEnv(SNOWFLAKE_HOME_KEY, tempPath.toString()); + SnowflakeUtil.systemSetEnv(SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY, "default"); + File tokenFile = new File(Paths.get(tempPath.toString(), "token").toUri()); + prepareConnectionConfigurationTomlFile( + Collections.singletonMap("token_file_path", tokenFile.toString()), true); + + ConnectionParameters data = SFConnectionConfigParser.buildConnectionParameters(); + assertNotNull(data); + assertEquals(tokenFile.toString(), data.getParams().get("token_file_path")); + assertEquals("testToken", data.getParams().get("token")); + } + + @Test + public void testThrowErrorWhenWrongPermissionsForTokenFile() throws IOException { + SnowflakeUtil.systemSetEnv(SNOWFLAKE_HOME_KEY, tempPath.toString()); + File tokenFile = new File(Paths.get(tempPath.toString(), "token").toUri()); + prepareConnectionConfigurationTomlFile( + Collections.singletonMap("token_file_path", tokenFile.toString()), false); + assumeFalse(RunningNotOnLinuxMac.isNotRunningOnLinuxMac()); + assertThrows( + SnowflakeSQLException.class, () -> SFConnectionConfigParser.buildConnectionParameters()); + } + + private void prepareConnectionConfigurationTomlFile( + Map moreParameters, boolean onlyUserPermission) throws IOException { + Path path = Paths.get(tempPath.toString(), "connections.toml"); + Path filePath = createFilePathWithPermission(path, onlyUserPermission); + File file = filePath.toFile(); + + Map configuration = new HashMap(); + Map configurationParams = new HashMap(); + configurationParams.put("account", "snowaccount.us-west-2.aws"); + configurationParams.put("user", "user1"); + configurationParams.put("token", "testToken"); + configurationParams.put("port", "443"); + + if (moreParameters != null) { + moreParameters.forEach((k, v) -> configurationParams.put(k, v)); + } + configuration.put("default", configurationParams); + tomlMapper.writeValue(file, configuration); + + if (configurationParams.containsKey("token_file_path")) { + Path tokenFilePath = + createFilePathWithPermission( + Paths.get(configurationParams.get("token_file_path").toString()), onlyUserPermission); + Files.write(tokenFilePath, "token_from_file".getBytes()); + } + } + + private Path createFilePathWithPermission(Path path, boolean onlyUserPermission) + throws IOException { + if (Constants.getOS() != Constants.OS.WINDOWS) { + FileAttribute> fileAttribute = + onlyUserPermission + ? PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")) + : PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxrw----")); + return Files.createFile(path, fileAttribute); + } else { + return Files.createFile(path); + } + } +} diff --git a/src/test/java/net/snowflake/client/jdbc/FileConnectionConfigurationLatestIT.java b/src/test/java/net/snowflake/client/jdbc/FileConnectionConfigurationLatestIT.java new file mode 100644 index 000000000..734446c92 --- /dev/null +++ b/src/test/java/net/snowflake/client/jdbc/FileConnectionConfigurationLatestIT.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012-2020 Snowflake Computing Inc. All right reserved. + */ +package net.snowflake.client.jdbc; + +import static net.snowflake.client.config.SFConnectionConfigParser.SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.After; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +/** This test could be run only on environment where file connection.toml is configured */ +@Ignore +public class FileConnectionConfigurationLatestIT { + + @After + public void cleanUp() { + SnowflakeUtil.systemUnsetEnv(SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY); + } + + @Test + public void testThrowExceptionIfConfigurationDoesNotExist() { + SnowflakeUtil.systemSetEnv("SNOWFLAKE_DEFAULT_CONNECTION_NAME", "non-existent"); + Assert.assertThrows(SnowflakeSQLException.class, () -> SnowflakeDriver.INSTANCE.connect()); + } + + @Test + public void testSimpleConnectionUsingFileConfigurationToken() throws SQLException { + verifyConnetionToSnowflake("aws-oauth"); + } + + @Test + public void testSimpleConnectionUsingFileConfigurationTokenFromFile() throws SQLException { + verifyConnetionToSnowflake("aws-oauth-file"); + } + + private static void verifyConnetionToSnowflake(String connectionName) throws SQLException { + SnowflakeUtil.systemSetEnv(SNOWFLAKE_DEFAULT_CONNECTION_NAME_KEY, connectionName); + try (Connection con = + DriverManager.getConnection(SnowflakeDriver.AUTO_CONNECTION_STRING_PREFIX, null); + Statement statement = con.createStatement(); + ResultSet resultSet = statement.executeQuery("show parameters")) { + Assert.assertTrue(resultSet.next()); + } + } +} diff --git a/thin_public_pom.xml b/thin_public_pom.xml index 239e31e34..e15a4e3c4 100644 --- a/thin_public_pom.xml +++ b/thin_public_pom.xml @@ -140,6 +140,10 @@ com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.dataformat + jackson-dataformat-toml + com.google.api gax