diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d4dbd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +build +target/ +*.iml diff --git a/README.md b/README.md index 7fd14d8..00b7348 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,89 @@ The AWS Secrets Manager Java caching client enables in-process caching of secrets for Java applications. +## Getting Started + +### Required Prerequisites +To use this client you must have: + +* **A Java 8 development environment** + + If you do not have one, go to [Java SE Downloads](https://www.oracle.com/technetwork/java/javase/downloads/index.html) on the Oracle website, then download and install the Java SE Development Kit (JDK). Java 8 or higher is recommended. + +An Amazon Web Services (AWS) account to access secrets stored in AWS Secrets Manager and use AWS SDK for Java. + +* **To create an AWS account**, go to [Sign In or Create an AWS Account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) and then choose **I am a new user.** Follow the instructions to create an AWS account. + +* **To create a secret in AWS Secrets Manager**, go to [Creating Secrets](https://docs.aws.amazon.com/secretsmanager/latest/userguide/manage_create-basic-secret.html) and follow the instructions on that page. + +* **To download and install the AWS SDK for Java**, go to [Installing the AWS SDK for Java](https://docs.aws.amazon.com/AWSSdkDocsJava/latest/DeveloperGuide/java-dg-install-sdk.html) in the AWS SDK for Java documentation and then follow the instructions on that page. + +### Download + +You can get the latest release from Maven: + +```xml + + com.amazonaws.secretsmanager + aws-secretsmanager-caching-java + 1.0.0 + +``` + +Don't forget to enable the download of snapshot jars from Maven: + +```xml + + + allow-snapshots + true + + + snapshots-repo + https://oss.sonatype.org/content/repositories/snapshots + false + true + + + + +``` + +### Get Started + +The following code sample demonstrates how to get started: + +1. Instantiate the caching client. +2. Request secret. + +```java +// This example shows how an AWS Lambda function can be written +// to retrieve a cached secret from AWS Secrets Manager caching +// client. +package com.amazonaws.secretsmanager.caching.examples; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.LambdaLogger; + +import com.amazonaws.secretsmanager.caching.SecretCache; + +/** + * SampleClass. + */ +public class SampleClass implements RequestHandler { + + private final SecretCache cache = new SecretCache(); + + @Override + public String handleRequest(String secretId, Context context) { + final String secret = cache.getSecretString(secretId); + // Use secret to connect to secured resource. + return "Success!"; + } +} +``` + ## License This library is licensed under the Apache 2.0 License. diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..ac1a2cb --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b3c3f1e --- /dev/null +++ b/pom.xml @@ -0,0 +1,148 @@ + + 4.0.0 + + com.amazonaws.secretsmanager + aws-secretsmanager-caching-java + 1.0.0 + jar + + + aws-secretsmanager-caching-java + AWS Secrets Manager caching client for Java + https://github.com/aws/aws-secretsmanager-caching-java + + + + Apache License, Version 2.0 + https://aws.amazon.com/apache2.0 + repo + + + + + + amazonwebservices + Amazon Web Services + https://aws.amazon.com + + developer + + + + + + https://github.com/aws/aws-secretsmanager-caching-java.git + + + + 1.8 + 1.8 + UTF-8 + 3.0.0 + 3.0.4 + + + + + com.amazonaws + aws-java-sdk-secretsmanager + 1.11.409 + + + org.testng + testng + 6.3.1 + test + + + org.mockito + mockito-all + 1.10.19 + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + 1.8 + 1.8 + -Xlint:all + true + true + + + + org.apache.maven.plugins + maven-source-plugin + 3.0.1 + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.0.1 + + + attach-javadocs + + jar + + + + + + maven-checkstyle-plugin + ${checkstyle.plugin.version} + + ${basedir}/config/checkstyle/checkstyle.xml + ${project.build.sourceEncoding} + true + true + false + true + + + + validate + validate + + check + + + + + + org.codehaus.mojo + findbugs-maven-plugin + ${findbugs.plugin.version} + + Max + Low + true + + + + analyze-compile + compile + + check + + + + + + + + diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/SecretCache.java b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCache.java new file mode 100644 index 0000000..fcd62a5 --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCache.java @@ -0,0 +1,164 @@ +/* + * 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.caching; + +import com.amazonaws.secretsmanager.caching.cache.LRUCache; +import com.amazonaws.secretsmanager.caching.cache.SecretCacheItem; +import com.amazonaws.services.secretsmanager.AWSSecretsManager; +import com.amazonaws.services.secretsmanager.AWSSecretsManagerClientBuilder; +import com.amazonaws.services.secretsmanager.model.GetSecretValueResult; + +import java.nio.ByteBuffer; + +/** + * Provides the primary entry-point to the AWS Secrets Manager client cache SDK. + * Most people will want to use either + * {@link #getSecretString(String)} or + * {@link #getSecretBinary(String)} to retrieve a secret from the cache. + * + *

+ * The core concepts (and classes) in this SDK are: + *

    + *
  • {@link SecretCache} + *
  • {@link SecretCacheConfiguration} + *
+ * + *

+ * {@link SecretCache} provides an in-memory cache for secrets requested from + * AWS Secrets Manager. + * + */ +public class SecretCache implements AutoCloseable { + + /** The cached secret items. */ + private final LRUCache cache; + + /** The cache configuration. */ + private final SecretCacheConfiguration config; + + /** The AWS Secrets Manager client to use when requesting secrets. */ + private final AWSSecretsManager client; + + /** + * Constructs a new secret cache using the standard AWS Secrets Manager client with default options. + */ + public SecretCache() { + this(AWSSecretsManagerClientBuilder.standard()); + } + + + /** + * Constructs a new secret cache using an AWS Secrets Manager client created using the + * provided builder. + * + * @param builder + * The builder to use for creating the AWS Secrets Manager client. + */ + public SecretCache(AWSSecretsManagerClientBuilder builder) { + this(null == builder ? + AWSSecretsManagerClientBuilder.standard().build() : + builder.build()); + } + + /** + * Constructs a new secret cache using the provided AWS Secrets Manager client. + * + * @param client + * The AWS Secrets Manager client to use for requesting secret values. + */ + public SecretCache(AWSSecretsManager client) { + this(new SecretCacheConfiguration().withClient(client)); + } + + /** + * Constructs a new secret cache using the provided cache configuration. + * + * @param config + * The secret cache configuration. + */ + public SecretCache(SecretCacheConfiguration config) { + if (null == config) { config = new SecretCacheConfiguration(); } + this.cache = new LRUCache(config.getMaxCacheSize()); + this.config = config; + this.client = config.getClient() != null ? config.getClient() : + AWSSecretsManagerClientBuilder.standard().build(); + } + + /** + * Method to retrieve the cached secret item. + * + * @param secretId + * The identifier for the secret being requested. + * @return The cached secret item + */ + private SecretCacheItem getCachedSecret(final String secretId) { + SecretCacheItem secret = this.cache.get(secretId); + if (null == secret) { + this.cache.putIfAbsent(secretId, + new SecretCacheItem(secretId, this.client, this.config)); + secret = this.cache.get(secretId); + } + return secret; + } + + /** + * Method to retrieve a string secret from AWS Secrets Manager. + * + * @param secretId + * The identifier for the secret being requested. + * @return The string secret + */ + public String getSecretString(final String secretId) { + SecretCacheItem secret = this.getCachedSecret(secretId); + GetSecretValueResult gsv = secret.getSecretValue(); + if (null == gsv) { return null; } + return gsv.getSecretString(); + } + + /** + * Method to retrieve a binary secret from AWS Secrets Manager. + * + * @param secretId + * The identifier for the secret being requested. + * @return The binary secret + */ + public ByteBuffer getSecretBinary(final String secretId) { + SecretCacheItem secret = this.getCachedSecret(secretId); + GetSecretValueResult gsv = secret.getSecretValue(); + if (null == gsv) { return null; } + return gsv.getSecretBinary(); + } + + /** + * Method to force the refresh of a cached secret state. + * + * @param secretId + * The identifier for the secret being refreshed. + * @return True if the refresh completed without error. + * @throws InterruptedException + * If the thread is interrupted while waiting for the refresh. + */ + public boolean refreshNow(final String secretId) throws InterruptedException { + SecretCacheItem secret = this.getCachedSecret(secretId); + return secret.refreshNow(); + } + + /** + * Method to close the cache. + */ + @Override + public void close() { + this.cache.clear(); + } + +} diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/SecretCacheConfiguration.java b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCacheConfiguration.java new file mode 100644 index 0000000..9b796b3 --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCacheConfiguration.java @@ -0,0 +1,235 @@ +/* + * 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.caching; + +import com.amazonaws.services.secretsmanager.AWSSecretsManager; + +import java.util.concurrent.TimeUnit; + + +/** + * Cache configuration options such as max cache size, ttl for cached items, etc. + * + */ +public class SecretCacheConfiguration { + + /** The default cache size. */ + public static final int DEFAULT_MAX_CACHE_SIZE = 1024; + + /** The default TTL for an item stored in cache before access causing a refresh. */ + public static final long DEFAULT_CACHE_ITEM_TTL = TimeUnit.HOURS.toMillis(1); + + /** The default version stage to use when retrieving secret values. */ + public static final String DEFAULT_VERSION_STAGE = "AWSCURRENT"; + + /** The client this cache instance will use for accessing AWS Secrets Manager. */ + private AWSSecretsManager client = null; + + /** Used to hook in-memory cache updates. */ + private SecretCacheHook cacheHook = null; + + /** + * The maximum number of cached secrets to maintain before evicting secrets that + * have not been accessed recently. + */ + private int maxCacheSize = DEFAULT_MAX_CACHE_SIZE; + + /** + * The number of milliseconds that a cached item is considered valid before + * requiring a refresh of the secret state. Items that have exceeded this + * TTL will be refreshed synchronously when requesting the secret value. If + * the synchronous refresh failed, the stale secret will be returned. + */ + private long cacheItemTTL = DEFAULT_CACHE_ITEM_TTL; + + /** + * The version stage that will be used when requesting the secret values for + * this cache. + */ + private String versionStage = DEFAULT_VERSION_STAGE; + + /** + * Default constructor for the SecretCacheConfiguration object. + * + */ + public SecretCacheConfiguration() { + } + + /** + * Returns the AWS Secrets Manager client that is used for requesting secret values. + * + * @return The AWS Secrets Manager client. + */ + public AWSSecretsManager getClient() { + return client; + } + + + /** + * Sets the AWS Secrets Manager client that should be used by the cache for requesting + * secrets. + * + * @param client + * The AWS Secrets Manager client. + */ + public void setClient(AWSSecretsManager client) { + this.client = client; + } + + /** + * Sets the AWS Secrets Manager client that should be used by the cache for requesting + * secrets. + * + * @param client + * The AWS Secrets Manager client. + * @return The updated ClientConfiguration object with the new client setting. + */ + public SecretCacheConfiguration withClient(AWSSecretsManager client) { + this.setClient(client); + return this; + } + + + /** + * Returns the interface used to hook in-memory cache updates. + * + * @return The object used to hook in-memory cache updates. + */ + public SecretCacheHook getCacheHook() { + return cacheHook; + } + + + /** + * Sets the interface used to hook the in-memory cache. + * + * @param cacheHook + * The interface used to hook the in-memory cache. + */ + public void setCacheHook(SecretCacheHook cacheHook) { + this.cacheHook = cacheHook; + } + + + /** + * Sets the interface used to hook the in-memory cache. + * + * @param cacheHook + * The interface used to hook in-memory cache. + * @return The updated ClientConfiguration object with the new setting. + */ + public SecretCacheConfiguration withCacheHook(SecretCacheHook cacheHook) { + this.setCacheHook(cacheHook); + return this; + } + + + /** + * Returns the max cache size that should be used for creating the cache. + * + * @return The max cache size. + */ + public int getMaxCacheSize() { + return this.maxCacheSize; + } + + /** + * Sets the max cache size. + * + * @param maxCacheSize + * The max cache size. + */ + public void setMaxCacheSize(int maxCacheSize) { + this.maxCacheSize = maxCacheSize; + } + + /** + * Sets the max cache size. + * + * @param maxCacheSize + * The max cache size. + * @return The updated ClientConfiguration object with the new max setting. + */ + public SecretCacheConfiguration withMaxCacheSize(int maxCacheSize) { + this.setMaxCacheSize(maxCacheSize); + return this; + } + + /** + * Returns the TTL for the cached items. + * + * @return The TTL in milliseconds before refreshing cached items. + */ + public long getCacheItemTTL() { + return this.cacheItemTTL; + } + + /** + * Sets the TTL in milliseconds for the cached items. Once cached items exceed this + * TTL, the item will be refreshed using the AWS Secrets Manager client. + * + * @param cacheItemTTL + * The TTL for cached items before requiring a refresh. + */ + public void setCacheItemTTL(long cacheItemTTL) { + this.cacheItemTTL = cacheItemTTL; + } + + /** + * Sets the TTL in milliseconds for the cached items. Once cached items exceed this + * TTL, the item will be refreshed using the AWS Secrets Manager client. + * + * @param cacheItemTTL + * The TTL for cached items before requiring a refresh. + * @return The updated ClientConfiguration object with the new TTL setting. + */ + public SecretCacheConfiguration withCacheItemTTL(long cacheItemTTL) { + this.setCacheItemTTL(cacheItemTTL); + return this; + } + + /** + * Returns the version stage that is used for requesting secret values. + * + * @return The version stage used in requesting secret values. + */ + public String getVersionStage() { + return this.versionStage; + } + + /** + * Sets the version stage that should be used for requesting secret values + * from AWS Secrets Manager + * + * @param versionStage + * The version stage used for requesting secret values. + */ + public void setVersionStage(String versionStage) { + this.versionStage = versionStage; + } + + /** + * Sets the version stage that should be used for requesting secret values + * from AWS Secrets Manager + * + * @param versionStage + * The version stage used for requesting secret values. + * @return The updated ClientConfiguration object with the new version stage setting. + */ + public SecretCacheConfiguration withVersionStage(String versionStage) { + this.setVersionStage(versionStage); + return this; + } + +} diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/SecretCacheHook.java b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCacheHook.java new file mode 100644 index 0000000..e347e1d --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCacheHook.java @@ -0,0 +1,38 @@ +/* + * 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.caching; + +/** + * Interface to hook the local in-memory cache. This interface will allow + * for clients to perform actions on the items being stored in the in-memory + * cache. One example would be encrypting/decrypting items stored in the + * in-memory cache. + */ +public interface SecretCacheHook{ + /** + * Prepare the object for storing in the cache + * + * @param o The object being stored in the cache + * @return The object that should be stored in the cached + */ + Object put(final Object o); + + /** + * Derive the object from the cached object. + * + * @param cachedObject The object stored in the cache + * @return The object that should be returned from the cache + */ + Object get(final Object cachedObject); +} \ No newline at end of file diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/cache/LRUCache.java b/src/main/java/com/amazonaws/secretsmanager/caching/cache/LRUCache.java new file mode 100644 index 0000000..2102a42 --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/cache/LRUCache.java @@ -0,0 +1,205 @@ +/* + * 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.caching.cache; + +import java.util.Map; +import java.util.LinkedHashMap; + +/** + * An LRU cache based on the Java LinkedHashMap. + * + */ +public class LRUCache { + + private static class LRULinkedHashMap extends LinkedHashMap { + private static final long serialVersionUID = 16L; + private final int maxSize; + + LRULinkedHashMap(int maxSize) { + super(16, .75f, true); + this.maxSize = maxSize; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return (this.size() > this.maxSize); + } + } + + /** The hash map used to hold the cached items. */ + private final LRULinkedHashMap map; + + /** The default max size for the cache */ + private static final int DEFAULT_MAX_SIZE = 1024; + + /** + * Construct a new cache with default settings. + */ + public LRUCache() { + this(DEFAULT_MAX_SIZE); + } + + /** + * Construct a new cache based on the given max size. + * + * @param maxSize + * The maximum number of items to store in the cache. + */ + public LRUCache(int maxSize) { + this.map = new LRULinkedHashMap(maxSize); + } + + /** + * Return the value mapped to the given key. + * + * @param key + * The key used to return the mapped value. + * @return The value mapped to the given key. + */ + public V get(final K key) { + synchronized (map) { + return map.get(key); + } + } + + /** + * Determine if the cache contains the given key. + * + * @param key + * The key used to determine it there is a mapped value. + * @return True if a value is mapped to the given key. + */ + public boolean containsKey(final K key) { + synchronized (map) { + return map.containsKey(key); + } + } + + /** + * Map a given key to the given value. + * + * @param key + * The key to map to the given value. + * @param value + * The value to map to the given key. + */ + public void put(final K key, final V value) { + synchronized (map) { + map.put(key, value); + } + } + + /** + * Return the previously mapped value and map a new value. + * + * @param key + * The key to map to the given value. + * @param value + * The value to map to the given key. + * @return The previously mapped value to the given key. + */ + public V getAndPut(final K key, final V value) { + synchronized (map) { + return map.put(key, value); + } + } + + /** + * Copies all of the mappings from the provided map to this cache. + * These mappings will replace any keys currently in the cache. + * + * @param map + * The mappings to copy. + */ + public void putAll(final Map map) { + synchronized (map) { + this.map.putAll(map); + } + } + + /** + * Map a given key to the given value if not already mapped. + * + * @param key + * The key to map to the given value. + * @param value + * The value to map to the given key. + * @return True if the mapping has been made. + */ + public boolean putIfAbsent(final K key, final V value) { + synchronized (map) { + return map.putIfAbsent(key, value) == null; + } + } + + /** + * Remove a given key from the cache. + * + * @param key + * The key to remove. + * @return True if the key has been removed. + */ + public boolean remove(final K key) { + synchronized (map) { + return map.remove(key) != null; + } + } + + /** + * Remove a given key and value from the cache. + * + * @param key + * The key to remove. + * @param oldValue + * The value to remove. + * @return True if the key and oldValue has been removed. + */ + public boolean remove(final K key, final V oldValue) { + synchronized (map) { + return map.remove(key, oldValue); + } + } + + /** + * Return the previously mapped value and remove the key. + * + * @param key + * The key to remove. + * @return The previously mapped value to the given key. + */ + public V getAndRemove(final K key) { + synchronized (map) { + return map.remove(key); + } + } + + /** + * Remove all of the cached items. + */ + public void removeAll() { + synchronized (map) { + map.clear(); + } + } + + /** + * Clear the cache. + */ + public void clear() { + synchronized (map) { + map.clear(); + } + } + +} diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheItem.java b/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheItem.java new file mode 100644 index 0000000..a53615b --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheItem.java @@ -0,0 +1,155 @@ +/* + * 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.caching.cache; + +import com.amazonaws.services.secretsmanager.AWSSecretsManager; +import com.amazonaws.services.secretsmanager.model.DescribeSecretRequest; +import com.amazonaws.services.secretsmanager.model.DescribeSecretResult; +import com.amazonaws.services.secretsmanager.model.GetSecretValueResult; +import com.amazonaws.secretsmanager.caching.SecretCacheConfiguration; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +/** + * The cached secret item which contains information from the DescribeSecret + * request to AWS Secrets Manager along with any associated GetSecretValue + * results. + * + */ +public class SecretCacheItem extends SecretCacheObject { + + /** The cached secret value versions for this cached secret. */ + private LRUCache versions = new LRUCache(10); + + /** + * The next scheduled refresh time for this item. Once the item is accessed + * after this time, the item will be synchronously refreshed. + */ + private long nextRefreshTime = 0; + + /** + * Construct a new cached item for the secret. + * + * @param secretId + * The secret identifier. This identifier could be the full ARN + * or the friendly name for the secret. + * @param client + * The AWS Secrets Manager client to use for requesting the secret. + * @param config + * Cache configuration. + */ + public SecretCacheItem(final String secretId, + final AWSSecretsManager client, + final SecretCacheConfiguration config) { + super(secretId, client, config); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof SecretCacheItem) { + return Objects.equals(this.secretId, ((SecretCacheItem)obj).secretId); + } + return false; + } + + @Override + public int hashCode() { + return String.format("%s", this.secretId).hashCode(); + } + + @Override + public String toString() { + return String.format("SecretCacheItem: %s", this.secretId); + } + + /** + * Determine if the secret object should be refreshed. This method + * extends the base class functionality to check if a refresh is + * needed based on the configured TTL for the item. + * + * @return True if the secret item should be refreshed. + */ + @Override + protected boolean isRefreshNeeded() { + if (super.isRefreshNeeded()) { return true; } + if (null != this.exception) { return false; } + if (System.currentTimeMillis() >= this.nextRefreshTime) { + return true; + } + return false; + } + + /** + * Execute the logic to perform the actual refresh of the item. + * + * @return The result from AWS Secrets Manager for the refresh. + */ + @Override + protected DescribeSecretResult executeRefresh() { + DescribeSecretResult describeSecretResult = client.describeSecret( + updateUserAgent(new DescribeSecretRequest() + .withSecretId(this.secretId))); + long ttl = this.config.getCacheItemTTL(); + this.nextRefreshTime = System.currentTimeMillis() + + ThreadLocalRandom.current().nextLong(ttl / 2,ttl + 1) ; + + return describeSecretResult; + } + + /** + * Return the secret version based on the current state of the secret. + * + * @param describeResult + * The result of the Describe Secret request to AWS Secrets Manager. + * @return The cached secret version. + */ + private SecretCacheVersion getVersion(DescribeSecretResult describeResult) { + if (null == describeResult) { return null; } + if (null == describeResult.getVersionIdsToStages()) { return null; } + Optional currentVersionId = describeResult.getVersionIdsToStages().entrySet() + .stream() + .filter(Objects::nonNull) + .filter(x -> x.getValue() != null) + .filter(x -> x.getValue().contains(this.config.getVersionStage())) + .map(x -> x.getKey()) + .findFirst(); + if (currentVersionId.isPresent()) { + SecretCacheVersion version = versions.get(currentVersionId.get()); + if (null == version) { + versions.putIfAbsent(currentVersionId.get(), + new SecretCacheVersion(this.secretId, currentVersionId.get(), this.client, this.config)); + version = versions.get(currentVersionId.get()); + } + return version; + } + return null; + } + + /** + * Return the cached result from AWS Secrets Manager for GetSecretValue. + * + * @param describeResult + * The result of the Describe Secret request to AWS Secrets Manager. + * @return The cached GetSecretValue result. + */ + @Override + protected GetSecretValueResult getSecretValue(DescribeSecretResult describeResult) { + SecretCacheVersion version = getVersion(describeResult); + if (null == version) { return null; } + return version.getSecretValue(); + } + +} diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheObject.java b/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheObject.java new file mode 100644 index 0000000..f3d1937 --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheObject.java @@ -0,0 +1,306 @@ +/* + * 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.caching.cache; + +import com.amazonaws.AmazonWebServiceRequest; +import com.amazonaws.services.secretsmanager.AWSSecretsManager; +import com.amazonaws.services.secretsmanager.model.GetSecretValueResult; +import com.amazonaws.secretsmanager.caching.cache.internal.VersionInfo; +import com.amazonaws.secretsmanager.caching.SecretCacheConfiguration; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Basic secret caching object. + */ +public abstract class SecretCacheObject { + + /** The number of milliseconds to wait after an exception. */ + private static final long EXCEPTION_BACKOFF = 1000; + + /** The growth factor of the backoff duration. */ + private static final long EXCEPTION_BACKOFF_GROWTH_FACTOR = 2; + + /** + * The maximum number of milliseconds to wait before retrying a failed + * request. + */ + private static final long BACKOFF_PLATEAU = EXCEPTION_BACKOFF * 128; + + /** + * When forcing a refresh using the refreshNow method, a random sleep + * will be performed using this value. This helps prevent code from + * executing a refreshNow in a continuous loop without waiting. + */ + private static final long FORCE_REFRESH_JITTER_SLEEP = 5000; + + /** The secret identifier for this cached object. */ + protected final String secretId; + + /** A private object to synchronize access to certain methods. */ + protected final Object lock = new Object(); + + /** The AWS Secrets Manager client to use for requesting secrets. */ + protected final AWSSecretsManager client; + + /** The Secret Cache Configuration. */ + protected final SecretCacheConfiguration config; + + /** A flag to indicate a refresh is needed. */ + private boolean refreshNeeded = true; + + /** The result of the last AWS Secrets Manager request for this item. */ + private Object data = null; + + /** + * If the last request to AWS Secrets Manager resulted in an exception, + * that exception will be thrown back to the caller when requesting + * secret data. + */ + protected RuntimeException exception = null; + + /** + * The number of exceptions encountered since the last successfully + * AWS Secrets Manager request. This is used to calculate an exponential + * backoff. + */ + private long exceptionCount = 0; + + /** + * The time to wait before retrying a failed AWS Secrets Manager request. + */ + private long nextRetryTime = 0; + + /** + * Construct a new cached item for the secret. + * + * @param secretId + * The secret identifier. This identifier could be the full ARN + * or the friendly name for the secret. + * @param client + * The AWS Secrets Manager client to use for requesting the secret. + * @param config + * The secret cache configuration. + */ + public SecretCacheObject(final String secretId, + final AWSSecretsManager client, + final SecretCacheConfiguration config) { + this.secretId = secretId; + this.client = client; + this.config = config; + } + + /** + * Execute the actual refresh of the cached secret state. + * + * @return The result of the refresh + */ + protected abstract T executeRefresh(); + + /** + * Execute the actual refresh of the cached secret state. + * + * @param result + * The AWS Secrets Manager result for the secret state. + * @return The cached GetSecretValue result based on the current + * cached state. + */ + protected abstract GetSecretValueResult getSecretValue(T result); + + public abstract boolean equals(Object obj); + public abstract int hashCode(); + public abstract String toString(); + + protected T updateUserAgent(T request) { + request.getRequestClientOptions().appendUserAgent(VersionInfo.USER_AGENT); + return request; + } + + /** + * Return the typed result object + * + * @return the result object + */ + @SuppressWarnings("unchecked") + private T getResult() { + if (null != this.config.getCacheHook()) { + return (T)this.config.getCacheHook().get(this.data); + } + return (T)this.data; + } + + /** + * Store the result data. + */ + private void setResult(T result) { + if (null != this.config.getCacheHook()) { + this.data = this.config.getCacheHook().put(result); + } else { + this.data = result; + } + } + + /** + * Determine if the secret object should be refreshed. + * + * @return True if the secret item should be refreshed. + */ + protected boolean isRefreshNeeded() { + if (this.refreshNeeded) { return true; } + if (null != this.exception) { + // If we encountered an exception on the last attempt + // we do not want to keep retrying without a pause between + // the refresh attempts. + // + // If we have exceeded our backoff time we will refresh + // the secret now. + if (System.currentTimeMillis() >= this.nextRetryTime) { + return true; + } + // Don't keep trying to refresh a secret that previously threw + // an exception. + return false; + } + return false; + } + + /** + * Refresh the cached secret state only when needed. + */ + private void refresh() { + if (!this.isRefreshNeeded()) { return; } + this.refreshNeeded = false; + try { + this.setResult(this.executeRefresh()); + this.exception = null; + this.exceptionCount = 0; + } catch (RuntimeException ex) { + this.exception = ex; + // Determine the amount of growth in exception backoff time based on the growth + // factor and default backoff duration. + Long growth = 1L; + if (this.exceptionCount > 0) { + growth = (long)Math.pow(EXCEPTION_BACKOFF_GROWTH_FACTOR, this.exceptionCount); + } + this.exceptionCount += 1; + growth *= EXCEPTION_BACKOFF; + // Add in EXCEPTION_BACKOFF time to make sure the random jitter will not reduce + // the wait time too low. + Long retryWait = Math.min(EXCEPTION_BACKOFF + growth, BACKOFF_PLATEAU); + // Use random jitter with the wait time + retryWait = ThreadLocalRandom.current().nextLong(retryWait / 2, retryWait + 1); + this.nextRetryTime = System.currentTimeMillis() + retryWait; + } + } + + /** + * Method to clone a List of String + * + * @param l + * The List of String + * @return The cloned List of String. + */ + private List clone(List l) { + if (null == l) { return null; } + return new ArrayList<>(l); + } + + /** + * Method to clone a ByteBuffer + * + * @param b + * The ByteBuffer to be cloned. + * @return The cloned ByteBuffer. + */ + private ByteBuffer clone(ByteBuffer b) { + // Nothing to clone, return null. + if (null == b) { return null; } + b.rewind(); + ByteBuffer clone = ByteBuffer.allocate(b.remaining()); + + if (b.hasArray()) { + System.arraycopy(b.array(), 0, clone.array(), 0, b.remaining()); + } + else { + clone.put(b.duplicate()); + clone.flip(); + } + + return clone; + } + + /** + * Method to force the refresh of a cached secret state. + * + * @return True if the refresh completed without error. + * @throws InterruptedException + * If the thread is interrupted while waiting for the refresh. + */ + public boolean refreshNow() throws InterruptedException { + this.refreshNeeded = true; + // When forcing a refresh, always sleep with a random jitter + // to prevent coding errors that could be calling refreshNow + // in a loop. + long sleep = ThreadLocalRandom.current() + .nextLong( + FORCE_REFRESH_JITTER_SLEEP / 2, + FORCE_REFRESH_JITTER_SLEEP + 1); + // Make sure we are not waiting for the next refresh after an + // exception. If we are, sleep based on the retry delay of + // the refresh to prevent a hard loop in attempting to refresh a + // secret that continues to throw an exception such as AccessDenied. + if (null != this.exception) { + long wait = this.nextRetryTime - System.currentTimeMillis(); + sleep = Math.max(wait, sleep); + } + Thread.sleep(sleep); + + // Perform the requested refresh + synchronized (lock) { + refresh(); + return (null == this.exception); + } + } + + /** + * Return the cached result from AWS Secrets Manager for GetSecretValue. + * + * @return The cached GetSecretValue result. + */ + public GetSecretValueResult getSecretValue() { + synchronized (lock) { + refresh(); + if (null == this.data) { + if (null != this.exception) { throw this.exception; } + } + GetSecretValueResult gsv = this.getSecretValue(this.getResult()); + + // If there is no cached result, return null. + if (null == gsv) { return null; } + + // We want to clone the result to prevent callers from modifying + // the cached data. + gsv = gsv.clone(); + // The prior clone did not perform a deep clone of all objects. + // Handle cloning the byte buffer it one exists. + gsv.setSecretBinary(clone(gsv.getSecretBinary())); + gsv.setVersionStages(clone(gsv.getVersionStages())); + return gsv; + } + } + +} diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheVersion.java b/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheVersion.java new file mode 100644 index 0000000..f8e7ab8 --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheVersion.java @@ -0,0 +1,101 @@ +/* + * 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.caching.cache; + +import com.amazonaws.secretsmanager.caching.SecretCacheConfiguration; +import com.amazonaws.services.secretsmanager.AWSSecretsManager; +import com.amazonaws.services.secretsmanager.model.GetSecretValueRequest; +import com.amazonaws.services.secretsmanager.model.GetSecretValueResult; + +import java.util.Objects; + +/** + * The cached secret version item which contains information from the + * GetSecretValue AWS Secrets Manager request. + */ +public class SecretCacheVersion extends SecretCacheObject { + + /** The version identifier to use when requesting the secret value. */ + private final String versionId; + + /** The calculated hash for this item based on the secret and version. */ + private final int hash; + + /** + * Construct a new cached version for the secret. + * + * @param secretId + * The secret identifier. This identifier could be the full ARN + * or the friendly name for the secret. + * @param versionId + * The version identifier that should be used when requesting the + * secret value from AWS Secrets Manager. + * @param client + * The AWS Secrets Manager client to use for requesting the secret. + * @param config + * The secret cache configuration. + */ + public SecretCacheVersion(final String secretId, + final String versionId, + final AWSSecretsManager client, + final SecretCacheConfiguration config) { + super(secretId, client, config); + this.versionId = versionId; + hash = String.format("%s %s", secretId, versionId).hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof SecretCacheVersion) { + return Objects.equals(this.secretId,((SecretCacheVersion)obj).secretId) && + Objects.equals(this.versionId, ((SecretCacheVersion)obj).versionId); + } + return false; + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public String toString() { + return String.format("SecretCacheVersion: %s %s", secretId, versionId); + } + + /** + * Execute the logic to perform the actual refresh of the item. + * + * @return The result from AWS Secrets Manager for the refresh. + */ + @Override + protected GetSecretValueResult executeRefresh() { + return client.getSecretValue( + updateUserAgent(new GetSecretValueRequest() + .withSecretId(this.secretId).withVersionId(this.versionId))); + } + + /** + * Return the cached result from AWS Secrets Manager for GetSecretValue. + * + * @param gsvResult + * The result of the Get Secret Value request to AWS Secrets Manager. + * @return The cached GetSecretValue result. + */ + @Override + protected GetSecretValueResult getSecretValue(GetSecretValueResult gsvResult) { + return gsvResult; + } + +} diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/cache/internal/VersionInfo.java b/src/main/java/com/amazonaws/secretsmanager/caching/cache/internal/VersionInfo.java new file mode 100644 index 0000000..aa212ad --- /dev/null +++ b/src/main/java/com/amazonaws/secretsmanager/caching/cache/internal/VersionInfo.java @@ -0,0 +1,35 @@ +/* + * 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.caching.cache.internal; + +/** + * This class specifies the versioning system for the AWS SecretsManager caching client. + */ +public class VersionInfo { + // incremented for design changes that break backward compatibility. + public static final String VERSION_NUM = "1"; + // incremented for major changes to the implementation + public static final String MAJOR_REVISION_NUM = "1"; + // incremented for minor changes to the implementation + public static final String MINOR_REVISION_NUM = "0"; + // incremented for releases containing an immediate bug fix. + public static final String BUGFIX_REVISION_NUM = "0"; + + public static final String RELEASE_VERSION = VERSION_NUM + "." + MAJOR_REVISION_NUM + "." + MINOR_REVISION_NUM + + "." + BUGFIX_REVISION_NUM; + + public static final String USER_AGENT = "AwsSecretCache/" + RELEASE_VERSION; + + private VersionInfo(){} +} \ No newline at end of file diff --git a/src/test/java/com/amazonaws/secretsmanager/caching/SecretCacheTest.java b/src/test/java/com/amazonaws/secretsmanager/caching/SecretCacheTest.java new file mode 100644 index 0000000..fdc246f --- /dev/null +++ b/src/test/java/com/amazonaws/secretsmanager/caching/SecretCacheTest.java @@ -0,0 +1,365 @@ +/* + * 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.caching; + +import com.amazonaws.services.secretsmanager.AWSSecretsManager; +import com.amazonaws.services.secretsmanager.AWSSecretsManagerClientBuilder; +import com.amazonaws.services.secretsmanager.model.DescribeSecretResult; +import com.amazonaws.services.secretsmanager.model.GetSecretValueResult; +import com.amazonaws.SdkClientException; + +import org.testng.annotations.Test; +import org.testng.annotations.BeforeMethod; +import org.testng.Assert; + +import org.mockito.MockitoAnnotations; +import org.mockito.Mockito; +import org.mockito.Mock; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.IntConsumer; + +/** + * SecretCacheTest. + */ +public class SecretCacheTest { + + @Mock + private AWSSecretsManager asm; + + @Mock + private DescribeSecretResult describeSecretResult; + + private GetSecretValueResult getSecretValueResult = new GetSecretValueResult(); + + @Mock + private SecretCacheConfiguration secretCacheConfiguration; + + @BeforeMethod + public void setUp() { + getSecretValueResult = new GetSecretValueResult().withVersionStages(Arrays.asList("v1")); + MockitoAnnotations.initMocks(this); + Mockito.when(asm.describeSecret(Mockito.any())).thenReturn(describeSecretResult); + Mockito.when(asm.getSecretValue(Mockito.any())).thenReturn(getSecretValueResult); + } + + private static void repeat(int number, IntConsumer c) { + for (int n = 0; n < number; ++n) { + c.accept(n); + } + } + + @Test(expectedExceptions = {SdkClientException.class}) + public void exceptionSecretCacheTest() { + SecretCache sc = new SecretCache(); + sc.getSecretString(""); + } + + @Test(expectedExceptions = {SdkClientException.class}) + public void exceptionSecretCacheConfigTest() { + try (SecretCache sc = new SecretCache(new SecretCacheConfiguration() + .withCacheItemTTL(SecretCacheConfiguration.DEFAULT_CACHE_ITEM_TTL) + .withMaxCacheSize(SecretCacheConfiguration.DEFAULT_MAX_CACHE_SIZE) + .withVersionStage(SecretCacheConfiguration.DEFAULT_VERSION_STAGE))) { + sc.getSecretString(""); + } + } + + @Test + public void secretCacheConstructorTest() { + // coverage for null parameters to constructor + SecretCache sc1 = null; + SecretCache sc2 = null; + try { + sc1 = new SecretCache((SecretCacheConfiguration)null); + } catch (Exception e) {} + try { + sc2 = new SecretCache((AWSSecretsManagerClientBuilder)null); + } catch (Exception e) {} + } + + @Test + public void basicSecretCacheTest() { + final String secret = "basicSecretCacheTest"; + Map> versionMap = new HashMap>(); + versionMap.put("versionId", Arrays.asList("AWSCURRENT")); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secret); + getSecretValueResult.setSecretBinary(ByteBuffer.wrap(secret.getBytes())); + SecretCache sc = new SecretCache(asm); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + + repeat(10, n -> Assert.assertEquals(sc.getSecretBinary(""), + ByteBuffer.wrap(secret.getBytes()))); + } + + @Test + public void hookSecretCacheTest() { + final String secret = "hookSecretCacheTest"; + Map> versionMap = new HashMap>(); + versionMap.put("versionId", Arrays.asList("AWSCURRENT")); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secret); + getSecretValueResult.setSecretBinary(ByteBuffer.wrap(secret.getBytes())); + class Hook implements SecretCacheHook { + private HashMap map = new HashMap(); + public Object put(final Object o) { + Integer key = map.size(); + map.put(key, o); + return key; + } + public Object get(final Object o) { + return map.get((Integer)o); + } + public int getCount() { return map.size(); } + } + Hook hook = new Hook(); + SecretCache sc = new SecretCache(new SecretCacheConfiguration() + .withClient(asm) + .withCacheHook(hook)); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + + repeat(10, n -> Assert.assertEquals(sc.getSecretBinary(""), + ByteBuffer.wrap(secret.getBytes()))); + Assert.assertEquals(hook.getCount(), 2); + } + + @Test + public void secretCacheNullStagesTest() { + final String secret = "basicSecretCacheTest"; + Map> versionMap = new HashMap>(); + versionMap.put("versionId", Arrays.asList("AWSCURRENT")); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secret); + getSecretValueResult.setSecretBinary(ByteBuffer.wrap(secret.getBytes())); + getSecretValueResult.setVersionStages(null); + SecretCache sc = new SecretCache(asm); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + + repeat(10, n -> Assert.assertEquals(sc.getSecretBinary(""), + ByteBuffer.wrap(secret.getBytes()))); + } + + @Test + public void basicSecretCacheRefreshNowTest() throws Throwable { + final String secret = "basicSecretCacheRefreshNowTest"; + Map> versionMap = new HashMap>(); + versionMap.put("versionId", Arrays.asList("AWSCURRENT")); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secret); + getSecretValueResult.setSecretBinary(ByteBuffer.wrap(secret.getBytes())); + SecretCache sc = new SecretCache(asm); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + + sc.refreshNow(""); + Mockito.verify(asm, Mockito.times(2)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + Mockito.verify(asm, Mockito.times(2)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + } + + @Test + public void basicSecretCacheByteBufferTest() { + final String secret = "basicSecretCacheByteBufferTest"; + Map> versionMap = new HashMap>(); + ByteBuffer buffer = ByteBuffer.allocateDirect(secret.getBytes().length); + buffer.put(secret.getBytes()); + versionMap.put("versionId", Arrays.asList("AWSCURRENT")); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secret); + getSecretValueResult.setSecretBinary(buffer); + SecretCache sc = new SecretCache(asm); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + + repeat(10, n -> Assert.assertEquals(sc.getSecretBinary(""), + ByteBuffer.wrap(secret.getBytes()))); + } + + @Test + public void basicSecretCacheMultipleTest() { + final String secretA = "basicSecretCacheMultipleTestA"; + final String secretB = "basicSecretCacheMultipleTestB"; + Map> versionMap = new HashMap>(); + versionMap.put("versionId", Arrays.asList("AWSCURRENT")); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secretA); + SecretCache sc = new SecretCache(asm); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString("SecretA"), secretA)); + + getSecretValueResult.setSecretString(secretB); + repeat(10, n -> Assert.assertEquals(sc.getSecretString("SecretB"), secretB)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(2)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(2)).getSecretValue(Mockito.any()); + } + + @Test + public void basicSecretCacheRefreshTest() throws Throwable { + final String secret = "basicSecretCacheRefreshTest"; + Map> versionMap = new HashMap>(); + versionMap.put("versionId", Arrays.asList("AWSCURRENT")); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secret); + SecretCache sc = new SecretCache(new SecretCacheConfiguration() + .withClient(asm) + .withCacheItemTTL(500)); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + + // Wait long enough to expire the TTL on the cached item. + Thread.sleep(600); + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), secret)); + // Verify that the refresh occurred after the ttl + Mockito.verify(asm, Mockito.times(2)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(1)).getSecretValue(Mockito.any()); + } + + @Test + public void basicSecretCacheTestNoVersions() { + final String secret = "basicSecretCacheTestNoVersion"; + getSecretValueResult.setSecretString(secret); + SecretCache sc = new SecretCache(asm); + + // Request the secret multiple times and verify the correct result + repeat(10, m -> Assert.assertEquals(sc.getSecretString(""), null)); + repeat(10, m -> Assert.assertEquals(sc.getSecretBinary(""), null)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(0)).getSecretValue(Mockito.any()); + } + + @Test(expectedExceptions = {RuntimeException.class}) + public void basicSecretCacheExceptionTest() { + final String secret = "basicSecretCacheExceptionTest"; + Mockito.when(asm.describeSecret(Mockito.any())).thenThrow(new RuntimeException()); + SecretCache sc = new SecretCache(asm); + sc.getSecretString(""); + } + + @Test + public void basicSecretCacheExceptionRefreshNowTest() throws Throwable { + final String secret = "basicSecretCacheExceptionTest"; + Mockito.when(asm.describeSecret(Mockito.any())).thenThrow(new RuntimeException()); + SecretCache sc = new SecretCache(asm); + Assert.assertFalse(sc.refreshNow("")); + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Assert.assertFalse(sc.refreshNow("")); + Mockito.verify(asm, Mockito.times(2)).describeSecret(Mockito.any()); + } + + @Test + public void basicSecretCacheExceptionRetryTest() throws Throwable { + final String secret = "basicSecretCacheExceptionTest"; + final int retryCount = 10; + Mockito.when(asm.describeSecret(Mockito.any())).thenThrow(new RuntimeException()); + SecretCache sc = new SecretCache(asm); + for (int n = 0; n < retryCount; ++n) { + try { + sc.getSecretString(""); + Assert.fail("Exception should have been thrown!"); + } catch (RuntimeException ex) { + } + } + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + + // Wait the backoff interval before retrying failed requests to verify + // a retry will be performed. + Thread.sleep(2100); + try { + sc.getSecretString(""); + Assert.fail("Exception should have been thrown!"); + } catch (RuntimeException ex) {} + // The api call should have been retried after the delay. + Mockito.verify(asm, Mockito.times(2)).describeSecret(Mockito.any()); + } + + @Test + public void basicSecretCacheNullTest() { + final String secret = "basicSecretCacheNullTest"; + Mockito.when(asm.describeSecret(Mockito.any())).thenReturn(null); + SecretCache sc = new SecretCache(asm); + Assert.assertNull(sc.getSecretString("")); + } + + @Test + public void basicSecretCacheNullStagesTest() { + final String secret = "basicSecretCacheNullStagesTest"; + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(null); + SecretCache sc = new SecretCache(asm); + Assert.assertNull(sc.getSecretString("")); + } + + @Test + public void basicSecretCacheVersionWithNullStageTest() { + final String secret = "basicSecretCacheTest"; + Map> versionMap = new HashMap>(); + versionMap.put("versionId", null); + Mockito.when(describeSecretResult.getVersionIdsToStages()).thenReturn(versionMap); + getSecretValueResult.setSecretString(secret); + SecretCache sc = new SecretCache(asm); + + // Request the secret multiple times and verify the correct result + repeat(10, n -> Assert.assertEquals(sc.getSecretString(""), null)); + + // Verify that multiple requests did not call the API + Mockito.verify(asm, Mockito.times(1)).describeSecret(Mockito.any()); + Mockito.verify(asm, Mockito.times(0)).getSecretValue(Mockito.any()); + } + +} diff --git a/src/test/java/com/amazonaws/secretsmanager/caching/cache/LRUCacheTest.java b/src/test/java/com/amazonaws/secretsmanager/caching/cache/LRUCacheTest.java new file mode 100644 index 0000000..4b6808b --- /dev/null +++ b/src/test/java/com/amazonaws/secretsmanager/caching/cache/LRUCacheTest.java @@ -0,0 +1,164 @@ +/* + * 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.caching.cache; + +import java.util.HashMap; + +import org.testng.annotations.Test; +import org.testng.Assert; + +public class LRUCacheTest { + + @Test + public void putIntTest() { + LRUCache cache = new LRUCache(); + cache.put(1, 1); + Assert.assertTrue(cache.containsKey(1)); + Assert.assertEquals(cache.get(1), new Integer(1)); + } + + @Test + public void removeTest() { + LRUCache cache = new LRUCache(); + cache.put(1, 1); + Assert.assertNotNull(cache.get(1)); + Assert.assertTrue(cache.remove(1)); + Assert.assertNull(cache.get(1)); + Assert.assertFalse(cache.remove(1)); + Assert.assertNull(cache.get(1)); + } + + @Test + public void removeAllTest() { + int max = 100; + LRUCache cache = new LRUCache(); + for (int n = 0; n < max; ++n) { + cache.put(n, n); + } + cache.removeAll(); + for (int n = 0; n < max; ++n) { + Assert.assertNull(cache.get(n)); + } + } + + @Test + public void clearTest() { + int max = 100; + LRUCache cache = new LRUCache(); + for (int n = 0; n < max; ++n) { + cache.put(n, n); + } + cache.clear(); + for (int n = 0; n < max; ++n) { + Assert.assertNull(cache.get(n)); + } + } + + @Test + public void getAndRemoveTest() { + LRUCache cache = new LRUCache(); + cache.put(1, 1); + Assert.assertNotNull(cache.get(1)); + Assert.assertEquals(cache.getAndRemove(1), new Integer(1)); + Assert.assertNull(cache.get(1)); + } + + @Test + public void removeWithValueTest() { + LRUCache cache = new LRUCache(); + cache.put(1, 1); + Assert.assertNotNull(cache.get(1)); + Assert.assertFalse(cache.remove(1, 2)); + Assert.assertNotNull(cache.get(1)); + Assert.assertTrue(cache.remove(1, 1)); + Assert.assertNull(cache.get(1)); + } + + @Test + public void putStringTest() { + LRUCache cache = new LRUCache(); + cache.put("a", "a"); + Assert.assertTrue(cache.containsKey("a")); + Assert.assertEquals(cache.get("a"), "a"); + } + + @Test + public void putIfAbsentTest() { + LRUCache cache = new LRUCache(); + Assert.assertTrue(cache.putIfAbsent("a", "a")); + Assert.assertFalse(cache.putIfAbsent("a", "a")); + } + + @Test + public void maxSizeTest() { + int maxCache = 5; + int max = 100; + LRUCache cache = new LRUCache(maxCache); + for (int n = 0; n < max; ++n) { + cache.put(n, n); + } + for (int n = 0; n < max - maxCache; ++n) { + Assert.assertNull(cache.get(n)); + } + for (int n = max - maxCache; n < max; ++n) { + Assert.assertNotNull(cache.get(n)); + Assert.assertEquals(cache.get(n), new Integer(n)); + } + } + + @Test + public void maxSizeLRUTest() { + int maxCache = 5; + int max = 100; + LRUCache cache = new LRUCache(maxCache); + for (int n = 0; n < max; ++n) { + cache.put(n, n); + Assert.assertEquals(cache.get(0), new Integer(0)); + } + for (int n = 1; n < max - maxCache; ++n) { + Assert.assertNull(cache.get(n)); + } + for (int n = max - maxCache + 1; n < max; ++n) { + Assert.assertNotNull(cache.get(n)); + Assert.assertEquals(cache.get(n), new Integer(n)); + } + Assert.assertEquals(cache.get(0), new Integer(0)); + } + + @Test + public void getAndPutTest() { + int max = 100; + Integer prev = null; + LRUCache cache = new LRUCache(); + for (int n = 0; n < max; ++n) { + Assert.assertEquals(cache.getAndPut(1, n), prev); + prev = n; + } + } + + @Test + public void putAllTest() { + int max = 100; + HashMap m = new HashMap(); + LRUCache cache = new LRUCache(); + for (int n = 0; n < max; ++n) { + m.put(n, n); + } + cache.putAll(m); + for (int n = 0; n < max; ++n) { + Assert.assertEquals(cache.get(n), new Integer(n)); + } + } + +} diff --git a/src/test/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheItemTest.java b/src/test/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheItemTest.java new file mode 100644 index 0000000..5c95f37 --- /dev/null +++ b/src/test/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheItemTest.java @@ -0,0 +1,32 @@ +/* + * 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.caching.cache; + +import org.testng.annotations.Test; +import org.testng.Assert; + +public class SecretCacheItemTest { + @Test + public void cacheItemEqualsTest() { + SecretCacheItem i1 = new SecretCacheItem("test", null, null); + SecretCacheItem i2 = new SecretCacheItem("test", null, null); + SecretCacheItem i3 = new SecretCacheItem("test3", null, null); + Assert.assertEquals(i1, i2); + Assert.assertNotEquals(i1, null); + Assert.assertNotEquals(i1, i3); + Assert.assertFalse(i1.equals(null)); + Assert.assertEquals(i1.hashCode(), i2.hashCode()); + Assert.assertNotEquals(i1.hashCode(), i3.hashCode()); + } +} diff --git a/src/test/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheVersionTest.java b/src/test/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheVersionTest.java new file mode 100644 index 0000000..548a504 --- /dev/null +++ b/src/test/java/com/amazonaws/secretsmanager/caching/cache/SecretCacheVersionTest.java @@ -0,0 +1,35 @@ +/* + * 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.caching.cache; + +import org.testng.Assert; +import org.testng.annotations.Test; + +public class SecretCacheVersionTest { + @Test + public void cacheVersionEqualsTest() { + SecretCacheVersion i1 = new SecretCacheVersion("test", "version", null, null); + SecretCacheVersion i2 = new SecretCacheVersion("test", "version", null, null); + SecretCacheVersion i3 = new SecretCacheVersion("test3", "version", null, null); + SecretCacheVersion i4 = new SecretCacheVersion("test", "version4", null, null); + Assert.assertEquals(i1, i2); + Assert.assertNotEquals(i1, null); + Assert.assertNotEquals(i1, i3); + Assert.assertNotEquals(i1, i4); + Assert.assertFalse(i1.equals(null)); + Assert.assertEquals(i1.hashCode(), i2.hashCode()); + Assert.assertNotEquals(i1.hashCode(), i3.hashCode()); + Assert.assertNotEquals(i1.hashCode(), i4.hashCode()); + } +}