Skip to content

Commit

Permalink
Merge pull request #119 from rundeck-plugins/secret-key-storage-path
Browse files Browse the repository at this point in the history
RUN-875: Add field/option to pull AWS Secret from Key Storage
  • Loading branch information
qualman authored May 17, 2022
2 parents f9fffb5 + 3fbba6a commit d2cf626
Show file tree
Hide file tree
Showing 6 changed files with 211 additions and 34 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# IDE/Build files
.gradle/
*.ipr
*.iml
*.iws
.idea/
build/

# System files
**/.DS_Store
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ repositories {
}
dependencies {
compile group:"org.slf4j", name:"slf4j-api", version:"1.7.30"
compile group: 'org.rundeck', name: 'rundeck-core', version: '2.2.2'
compile (group: 'org.rundeck', name: 'rundeck-core', version: '3.4.0-20210614') {
exclude group: "com.google.guava"
}
compile "com.amazonaws:aws-java-sdk-core:1.11.743"
compile "com.amazonaws:aws-java-sdk-sts:1.11.743"
compile "com.fasterxml.jackson.core:jackson-databind:2.10.5.1"
Expand All @@ -78,6 +80,7 @@ dependencies {
testCompile "org.codehaus.groovy:groovy-all:2.3.7"
testCompile "org.spockframework:spock-core:0.7-groovy-2.0"
testCompile "cglib:cglib-nodep:2.2.2"
testCompile 'org.objenesis:objenesis:3.1'
}

// task to copy plugin libs to output/lib dir
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
import com.dtolabs.rundeck.core.plugins.configuration.ConfigurationException;
import com.dtolabs.rundeck.core.resources.ResourceModelSource;
import com.dtolabs.rundeck.core.resources.ResourceModelSourceException;
import com.dtolabs.rundeck.core.storage.keys.KeyStorageTree;
import org.rundeck.app.spi.Services;
import org.rundeck.storage.api.PathUtil;
import org.rundeck.storage.api.StorageException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -62,6 +66,7 @@ public class EC2ResourceModelSource implements ResourceModelSource {
static Logger logger = LoggerFactory.getLogger(EC2ResourceModelSource.class);
private String accessKey;
private String secretKey;
private String secretKeyStoragePath;
long refreshInterval = 30000;
long lastRefresh = 0;
String filterParams;
Expand All @@ -72,6 +77,7 @@ public class EC2ResourceModelSource implements ResourceModelSource {
String httpProxyPass;
String mappingParams;
File mappingFile;
Services services;
boolean useDefaultMapping = true;
boolean runningOnly = false;
boolean queryAsync = true;
Expand Down Expand Up @@ -141,9 +147,10 @@ public class EC2ResourceModelSource implements ResourceModelSource {
}
}

public EC2ResourceModelSource(final Properties configuration) {
public EC2ResourceModelSource(final Properties configuration, final Services services) {
this.accessKey = configuration.getProperty(EC2ResourceModelSourceFactory.ACCESS_KEY);
this.secretKey = configuration.getProperty(EC2ResourceModelSourceFactory.SECRET_KEY);
this.secretKeyStoragePath = configuration.getProperty(EC2ResourceModelSourceFactory.SECRET_KEY_STORAGE_PATH);
this.endpoint = configuration.getProperty(EC2ResourceModelSourceFactory.ENDPOINT);
this.pageResults = Integer.parseInt(configuration.getProperty(EC2ResourceModelSourceFactory.MAX_RESULTS));
this.httpProxyHost = configuration.getProperty(EC2ResourceModelSourceFactory.HTTP_PROXY_HOST);
Expand Down Expand Up @@ -186,13 +193,19 @@ public EC2ResourceModelSource(final Properties configuration) {
EC2ResourceModelSourceFactory.RUNNING_ONLY));
logger.info("[debug] runningOnly:" + runningOnly);
}
if (null != accessKey && null != secretKey) {
if (null != accessKey && null != secretKeyStoragePath) {

KeyStorageTree keyStorage = services.getService(KeyStorageTree.class);
String secretKey = getPasswordFromKeyStorage(secretKeyStoragePath, keyStorage);

credentials = new BasicAWSCredentials(accessKey.trim(), secretKey.trim());
assumeRoleArn = null;
}else{
}else if (null != accessKey && null != secretKey) {
credentials = new BasicAWSCredentials(accessKey.trim(), secretKey.trim());
assumeRoleArn = null;
} else {
assumeRoleArn = configuration.getProperty(EC2ResourceModelSourceFactory.ROLE_ARN);
}

if (null != httpProxyHost && !"".equals(httpProxyHost)) {
clientConfiguration.setProxyHost(httpProxyHost);
clientConfiguration.setProxyPort(httpProxyPort);
Expand Down Expand Up @@ -225,7 +238,6 @@ private void initialize() {
);
}


mapper = new InstanceToNodeMapper(this.credentials, mapping, clientConfiguration, pageResults);
mapper.setFilterParams(params);
mapper.setEndpoint(endpoint);
Expand Down Expand Up @@ -321,9 +333,21 @@ private void loadMapping() {
}

public void validate() throws ConfigurationException {
if (null != accessKey && null == secretKey) {
if (null != accessKey && null == secretKey && null == secretKeyStoragePath) {
throw new ConfigurationException("secretKey is required for use with accessKey");
}
}

static String getPasswordFromKeyStorage(String path, KeyStorageTree storage) {
try{
String key = new String(storage.readPassword(path));
return key;
}catch (Exception e){
throw StorageException.readException(
PathUtil.asPath(path),
"error accessing key storage at " + path + ": " + e.getMessage()
);
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import com.dtolabs.rundeck.core.resources.ResourceModelSourceFactory;
import com.dtolabs.rundeck.plugins.util.DescriptionBuilder;
import com.dtolabs.rundeck.plugins.util.PropertyBuilder;
import org.rundeck.app.spi.Services;

import java.io.File;
import java.util.*;
Expand Down Expand Up @@ -59,6 +60,7 @@ public class EC2ResourceModelSourceFactory implements ResourceModelSourceFactory
public static final String RUNNING_ONLY = "runningOnly";
public static final String ACCESS_KEY = "accessKey";
public static final String SECRET_KEY = "secretKey";
public static final String SECRET_KEY_STORAGE_PATH = "secretKeyStoragePath";
public static final String ROLE_ARN = "assumeRoleArn";
public static final String MAPPING_FILE = "mappingFile";
public static final String REFRESH_INTERVAL = "refreshInterval";
Expand All @@ -74,12 +76,16 @@ public EC2ResourceModelSourceFactory(final Framework framework) {
this.framework = framework;
}

public ResourceModelSource createResourceModelSource(final Properties properties) throws ConfigurationException {
final EC2ResourceModelSource ec2ResourceModelSource = new EC2ResourceModelSource(properties);
public ResourceModelSource createResourceModelSource(Services services, final Properties configuration) throws ConfigurationException {
final EC2ResourceModelSource ec2ResourceModelSource = new EC2ResourceModelSource(configuration, services);
ec2ResourceModelSource.validate();
return ec2ResourceModelSource;
}

public ResourceModelSource createResourceModelSource(Properties configuration) throws ConfigurationException {
return null;
}

static Description DESC = DescriptionBuilder.builder()
.name(PROVIDER_NAME)
.title("AWS EC2 Resources")
Expand All @@ -90,14 +96,30 @@ public ResourceModelSource createResourceModelSource(final Properties properties
PropertyUtil.string(
SECRET_KEY,
"Secret Key",
"AWS Secret Key, required if Access Key is used. If not used, then the IAM profile will be used",
"AWS Secret Key. Required if Access Key is used and Secret Key Storage Path is blank.\nIf `Access Key` is not used, then the IAM profile will be used.",
false,
null,
null,
null,
Collections.singletonMap("displayType", (Object) StringRenderingConstants.DisplayType.PASSWORD)
)
)
.property(
PropertyUtil.string(
SECRET_KEY_STORAGE_PATH,
"Secret Key Storage Path",
"Key Storage Path for AWS Secret Key. Required if Access Key is used and Secret Key is blank.\nIf `Access Key` is not used, then the IAM profile will be used.",
false,
null,
null,
null,
new HashMap<String, Object>(){{
put(StringRenderingConstants.SELECTION_ACCESSOR_KEY,StringRenderingConstants.SelectionAccessor.STORAGE_PATH);
put(StringRenderingConstants.STORAGE_PATH_ROOT_KEY,"keys");
put(StringRenderingConstants.STORAGE_FILE_META_FILTER_KEY, "Rundeck-data-type=password");
}}
)
)
.property(
PropertyUtil.string(
ROLE_ARN,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package com.dtolabs.rundeck.plugin.resources.ec2

import com.dtolabs.rundeck.core.common.Framework
import com.dtolabs.rundeck.core.common.IRundeckProject
import com.dtolabs.rundeck.core.common.ProjectManager
import com.dtolabs.rundeck.core.storage.keys.KeyStorageTree
import org.rundeck.app.spi.Services
import org.rundeck.storage.api.StorageException
import spock.lang.Specification

class EC2ResourceModelSourceSpec extends Specification {
def "user configured access credentials prefer key storage"() {
given: "a user's plugin config"
//Define good and bad keys and paths
def validAccessKey = "validAccessKey"
def validSecretKey = "validSecretKey"
def validKeyPath = "keys/validKeyPath"
def badPath = "keys/badPath"
def badPass = "myNetflixPassword"

// Mock services and Key Storage return of passwords
def serviceWithGoodPass = mockServicesWithPassword(validKeyPath, validSecretKey)
def serviceWithBadPass = mockServicesWithPassword(badPath, badPass)

// Create a default config object (these are the settings the user would setup via the Plugin UI)
def defaultConfig = createDefaultConfig()
defaultConfig.setProperty(EC2ResourceModelSourceFactory.ACCESS_KEY, validAccessKey)

// Create a working config from the defaults
def workingConfig = new Properties()
workingConfig.putAll(defaultConfig)
// Send a bad key to ensure key path takes precedence and succeeds
workingConfig.setProperty(EC2ResourceModelSourceFactory.SECRET_KEY, badPass)
workingConfig.setProperty(EC2ResourceModelSourceFactory.SECRET_KEY_STORAGE_PATH, validKeyPath)

// Create a failing config from the defaults
def failingConfig = new Properties()
failingConfig.putAll(defaultConfig)
// Send a valid key to ensure storage path takes precedence and fails
failingConfig.setProperty(EC2ResourceModelSourceFactory.SECRET_KEY, validSecretKey)
failingConfig.setProperty(EC2ResourceModelSourceFactory.SECRET_KEY_STORAGE_PATH, badPath)

// Create objects using actual ResourceModelSource and Factory
def workingRms = ec2ResourceModelSource(serviceWithGoodPass, workingConfig)
def failingRms = ec2ResourceModelSource(serviceWithBadPass, failingConfig)

when: "we check the access keys of the resource model source objects"
// Instead of using getNodes, which would all be highly mocked, just check that we got as far as setting
// proper credentials right before the point we would call to AWS
def workingRmsPass = workingRms.credentials.getAWSSecretKey()
def failingRmsPass = failingRms.credentials.getAWSSecretKey()

then: "we see that the proper keys from the key storage or the inline key have been derived"
workingRmsPass == validSecretKey
failingRmsPass == badPass
}
def "fail properly when invalid key path is provided"() {
given: "User plugin config that uses an invalid key path"
//Define good and bad keys and paths
def validAccessKey = "validAccessKey"
def goodKeyPath = "keys/validKeyPath"
def badPath = "keys/badPath"
def badPass = "myNetflixPassword"

// Mock services and Key Storage return of passwords
def serviceWithBadPass = mockServicesWithPassword(badPath, null)

// Create a default config object (these are the settings the user would setup via the Plugin UI)
def config = createDefaultConfig()
config.setProperty(EC2ResourceModelSourceFactory.ACCESS_KEY, validAccessKey)
config.setProperty(EC2ResourceModelSourceFactory.SECRET_KEY_STORAGE_PATH, badPath)

when: "user attempts to create EC2ResourceModelSource instance using invalid path"
def failingRms = ec2ResourceModelSource(serviceWithBadPass, config)

then: "expect a StorageException#readException to be returned"
StorageException ex = thrown()
ex.message.contains("error accessing key storage at ${badPath}")
}
//
// Private Methods
//
private def createDefaultConfig() {
def configuration = new Properties()
def assumeRoleArn = "arn:aws:iam::123456789012:role/fake-test-arn"
def endpoint = "ALL_REGIONS"
def pageResults = "100"
def proxyPortStr = "80"
def refreshStr = "30"
def useDefaultMapping = "true"
def runningOnly = "true"

configuration.setProperty(EC2ResourceModelSourceFactory.ROLE_ARN, assumeRoleArn)
configuration.setProperty(EC2ResourceModelSourceFactory.ENDPOINT, endpoint);
configuration.setProperty(EC2ResourceModelSourceFactory.MAX_RESULTS, pageResults);
configuration.setProperty(EC2ResourceModelSourceFactory.HTTP_PROXY_PORT, proxyPortStr);
configuration.setProperty(EC2ResourceModelSourceFactory.REFRESH_INTERVAL, refreshStr);
configuration.setProperty(EC2ResourceModelSourceFactory.USE_DEFAULT_MAPPING, useDefaultMapping)
configuration.setProperty(EC2ResourceModelSourceFactory.RUNNING_ONLY, runningOnly)

return configuration
}

private def ec2ResourceModelSource(Services services, Properties configuration) {
def framework = Mock(Framework)
def factory = new EC2ResourceModelSourceFactory(framework)

return factory.createResourceModelSource(services, configuration)
}

private def mockServicesWithPassword(String path, String password) {
def storageTree = Mock(KeyStorageTree) {
readPassword(path) >> {
return password.bytes
}
}

def services = Mock(Services) {
getService(KeyStorageTree.class) >> storageTree
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,28 +160,6 @@ class InstanceToNodeMapperSpec extends Specification {
'instanceId|tags/Name+"_"+tags/env' | null | 'aninstanceId,bob_PROD'
}

private static Instance mkInstance() {
Instance i = new Instance()
i.withTags(new Tag('Name', 'bob'), new Tag('env', 'PROD'))
i.setInstanceId("aninstanceId")
i.setArchitecture("anarch")
i.setImageId("ami-something")
i.setPlacement(new Placement("us-east-1a"))

def state = new InstanceState()
state.setName(InstanceStateName.Running)
i.setState(state)
i.setPrivateIpAddress('127.0.9.9')
return i
}

private static Image mkImage(){
Image image = new Image()
image.setImageId("ami-something")
image.setName("AMISomething")
return image
}

def "extra mapping image"() {
given:

Expand Down Expand Up @@ -223,8 +201,6 @@ class InstanceToNodeMapperSpec extends Specification {
'imageId+"-"+imageName' | "ami_image"
}



def "extra mapping not calling image list"() {
given:

Expand Down Expand Up @@ -301,4 +277,29 @@ class InstanceToNodeMapperSpec extends Specification {
instances.getNode("aninstanceId").getAttributes().get("region") == "us-east-1"

}

//
// Private Methods
//
private static Instance mkInstance() {
Instance i = new Instance()
i.withTags(new Tag('Name', 'bob'), new Tag('env', 'PROD'))
i.setInstanceId("aninstanceId")
i.setArchitecture("anarch")
i.setImageId("ami-something")
i.setPlacement(new Placement("us-east-1a"))

def state = new InstanceState()
state.setName(InstanceStateName.Running)
i.setState(state)
i.setPrivateIpAddress('127.0.9.9')
return i
}

private static Image mkImage(){
Image image = new Image()
image.setImageId("ami-something")
image.setName("AMISomething")
return image
}
}

0 comments on commit d2cf626

Please sign in to comment.