/* * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with * the License. A copy of the License is located at * * http://aws.amazon.com/apache2.0 * * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ package com.amazonaws.secretsmanager.sql; import com.amazonaws.secretsmanager.util.Config; import com.amazonaws.secretsmanager.caching.SecretCache; import com.amazonaws.secretsmanager.caching.SecretCacheConfiguration; import com.amazonaws.secretsmanager.util.JDBCSecretCacheBuilderProvider; import com.amazonaws.services.secretsmanager.AWSSecretsManager; import com.amazonaws.services.secretsmanager.AWSSecretsManagerClientBuilder; import com.amazonaws.util.StringUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.sql.Connection; import java.sql.Driver; import java.sql.DriverManager; import java.sql.DriverPropertyInfo; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.util.Enumeration; import java.util.Properties; import java.util.logging.Logger; /** *
* Provides support for accessing SQL databases using credentials stored within AWS Secrets Manager. If this * functionality is desired, then a subclass of this class should be specified as the JDBC driver for an application. *
* *
* The driver to propagate connect
requests to should also be specified in the configuration. Doing this
* will cause the real driver to be registered once an instance of this driver is made (which will be when this driver
* is registered).
*
* This base class registers itself with the java.sql.DriverManager
when its constructor is called. That
* means a subclass only needs to make a new instance of itself in its static block to register.
*
* This does not support including the user (secret ID) and password in the jdbc url, as JDBC url formats are database * specific. If this functionality is desired, it must be implemented in a subclass. *
* ** Ignores the password field, drawing a secret ID from the user field. The secret referred to by this field is * expected to be in the standard JSON format used by the rotation lambdas provided by Secrets Manager: *
* ** {@code * { * "username": "xxxx", * "password": "xxxx", * ... * } * } ** *
* Here is a list of the configuration properties. The subprefix is an implementation specific String used to keep
* the properties for different drivers separate. For example, the MySQL driver wrapper might use mysql as its
* subprefix, making the full property name for the realDriverClass for the MySQL driver wrapper
* drivers.mysql.realDriverClass (all Driver properties will be prefixed with "drivers."). This String is defined by
* the method getPropertySubprefix
.
*
realDriverClass
*/
private void loadRealDriver() {
try {
Class.forName(this.realDriverClass);
} catch (ClassNotFoundException e) {
throw new IllegalStateException("Could not load real driver with name, \"" + this.realDriverClass + "\".", e);
}
}
/**
* Called when the driver is deregistered to cleanup resources.
*/
private static void shutdown(AWSSecretsManagerDriver driver) {
driver.secretCache.close();
}
/**
* Registers a driver along with the DriverAction
implementation.
*
* @param driver The driver to register.
*
* @throws RuntimeException If the driver could not be registered.
*/
protected static void register(AWSSecretsManagerDriver driver) {
try {
DriverManager.registerDriver(driver, () -> shutdown(driver));
} catch (SQLException e) {
throw new RuntimeException("Driver could not be registered.", e);
}
}
/**
* Gets the "subprefix" used for configuration properties for this driver. For example, if this method returns the
* String, "mysql", then the real driver that this will forward requests to would be set to
* drivers.mysql.realDriverClass in the properties file or in the system properties.
*
* @return String The subprefix to use for configuration properties.
*/
public abstract String getPropertySubprefix();
/**
* Replaces SCHEME
in a jdbc url with "jdbc" in order to pass the url to the real driver.
*
* @param jdbcUrl The jdbc url with SCHEME
as the scheme.
*
* @return String The jdbc url with the scheme changed.
*
* @throws IllegalArgumentException When the url does not start with SCHEME
.
*/
private String unwrapUrl(String jdbcUrl) {
if (!jdbcUrl.startsWith(SCHEME)) {
throw new IllegalArgumentException("JDBC URL is malformed. Must use scheme, \"" + SCHEME + "\".");
}
return jdbcUrl.replaceFirst(SCHEME, "jdbc");
}
/**
* Returns an instance of the real java.sql.Driver
that this should propagate calls to. The real
* driver is specified by the realDriverClass property.
*
* @return Driver The real Driver
that calls should be
* propagated to.
*
* @throws IllegalStateException When there is no driver with the name
* realDriverClass
*/
public Driver getWrappedDriver() {
loadRealDriver();
EnumerationException
is due to an authentication failure with the remote
* database. This method is called during connect
to decide if authentication needs to be attempted
* again with refreshed credentials. A good way to implement this is to look up the error codes that
* java.sqlSQLException
s will have when an authentication failure occurs. These are database
* specific.
*
* @param exception The Exception
to test.
*
* @return boolean Whether or not the Exception
indicates that
* the credentials used for authentication are stale.
*/
public abstract boolean isExceptionDueToAuthenticationError(Exception exception);
/**
* Construct a database URL from the endpoint, port and database name. This method is called when the
* connect
method is called with a secret ID instead of a URL.
*
* @param endpoint The endpoint retrieved from the secret cache
* @param port The port retrieved from the secret cache
* @param dbname The database name retrieved from the secret cache
*
* @return String The constructed URL based on the endpoint and port
*/
public abstract String constructUrlFromEndpointPortDatabase(String endpoint, String port, String dbname);
/**
* Get the default real driver class name for this driver.
*
* @return String The default real driver class name
*/
public abstract String getDefaultDriverClass();
/**
* Calls the real driver's connect
method using credentials from a secret stored in AWS Secrets
* Manager.
*
* @param unwrappedUrl The jdbc url that the real driver will accept.
* @param info The information to pass along to the real driver. The
* user and password fields will be replaced with the
* credentials retrieved from Secrets Manager.
* @param credentialsSecretId The friendly name or ARN of the secret that stores the
* login credentials.
*
* @return Connection A database connection.
*
* @throws SQLException If there is an error from the driver or underlying
* database.
* @throws InterruptedException If there was an interruption during secret refresh.
*/
private Connection connectWithSecret(String unwrappedUrl, Properties info, String credentialsSecretId)
throws SQLException, InterruptedException {
int retryCount = 0;
while (retryCount++ <= MAX_RETRY) {
String secretString = secretCache.getSecretString(credentialsSecretId);
Properties updatedInfo = new Properties(info);
try {
JsonNode jsonObject = mapper.readTree(secretString);
updatedInfo.setProperty("user", jsonObject.get("username").asText());
updatedInfo.setProperty("password", jsonObject.get("password").asText());
} catch (IOException | NullPointerException e) {
// Most likely to occur in the event that the data is not JSON.
// Or the secret's username and/or password fields have been
// removed entirely. Either scenario is most often a user error.
throw new RuntimeException(INVALID_SECRET_STRING_JSON);
}
try {
return getWrappedDriver().connect(unwrappedUrl, updatedInfo);
} catch (Exception e) {
if (isExceptionDueToAuthenticationError(e)) {
boolean refreshSuccess = this.secretCache.refreshNow(credentialsSecretId);
if (!refreshSuccess) {
throw(e);
}
}
else {
throw(e);
}
}
}
// Max retries reached
throw new SQLException("Connect failed to authenticate: reached max connection retries");
}
@Override
public Connection connect(String url, Properties info) throws SQLException {
if (!acceptsURL(url)) {
return null;
}
String unwrappedUrl = "";
if (url.startsWith(SCHEME)) { // If this is a URL in the correct scheme, unwrap it
unwrappedUrl = unwrapUrl(url);
} else { // Else, assume this is a secret ID and try to retrieve it
try {
String secretString = secretCache.getSecretString(url);
if (StringUtils.isNullOrEmpty(secretString)) {
throw new IllegalArgumentException("URL " + url + " is not a valid URL starting with scheme " +
SCHEME + " or a valid retrievable secret ID ");
}
JsonNode jsonObject = mapper.readTree(secretString);
String endpoint = jsonObject.get("host").asText();
JsonNode portNode = jsonObject.get("port");
String port = portNode == null ? null : portNode.asText();
JsonNode dbnameNode = jsonObject.get("dbname");
String dbname = dbnameNode == null ? null : dbnameNode.asText();
unwrappedUrl = constructUrlFromEndpointPortDatabase(endpoint, port, dbname);
} catch (IOException | NullPointerException e) {
// Most likely to occur in the event that the data is not JSON.
// Or the secret has been modified and is no longer valid.
// Either scenario is most often a user error.
throw new RuntimeException(INVALID_SECRET_STRING_JSON);
}
}
if (info != null && info.getProperty("user") != null) {
String credentialsSecretId = info.getProperty("user");
try {
return connectWithSecret(unwrappedUrl, info, credentialsSecretId);
} catch (InterruptedException e) {
// User driven exception. Throw a runtime exception.
throw new RuntimeException(e);
}
} else {
return getWrappedDriver().connect(unwrappedUrl, info);
}
}
@Override
public int getMajorVersion() {
return getWrappedDriver().getMajorVersion();
}
@Override
public int getMinorVersion() {
return getWrappedDriver().getMinorVersion();
}
@Override
public Logger getParentLogger() throws SQLFeatureNotSupportedException {
return getWrappedDriver().getParentLogger();
}
@Override
public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException {
return getWrappedDriver().getPropertyInfo(unwrapUrl(url), info);
}
@Override
public boolean jdbcCompliant() {
return getWrappedDriver().jdbcCompliant();
}
}