Skip to content

Commit dca7160

Browse files
authored
Add AWS IAM auth method (#166)
1 parent a35e28a commit dca7160

File tree

15 files changed

+580
-5
lines changed

15 files changed

+580
-5
lines changed

Diff for: README.md

+21-2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@ You enter your Vault Kubernetes auth `role`. The JWT will be automatically retri
7575
mounted secret volume (`/var/run/secrets/kubernetes.io/serviceaccount/token`). This assumes,
7676
that the jenkins is running in Kubernetes Pod with a Service Account attached.
7777

78+
#### Vault AWS IAM Credential
79+
80+
![AWS IAM Credential](docs/images/aws_iam_credential.png)
81+
82+
Authenticate to Vault using the aws auth method with the
83+
[IAM](https://www.vaultproject.io/docs/auth/aws#iam-auth-method)
84+
workflow. The AWS credentials will be automatically retrieved from one
85+
of [several standard
86+
locations](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html).
87+
The typical use case would be Jenkins master running on an AWS EC2
88+
instance with the credentials acquired from the [instance
89+
metadata](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials).
90+
Optionally enter your AWS IAM auth `role` name and Vault AWS auth
91+
`mount` path. If the `role` is not provided, Vault will determine it
92+
from the principal in the IAM identity. If the `mount` path is not
93+
provided, it defaults to `aws`.
94+
7895
#### Vault Token Credential
7996

8097
![Token Credential](docs/images/token_credential.png)
@@ -224,7 +241,7 @@ credentials:
224241
225242
See [handling secrets section](https://github.com/jenkinsci/configuration-as-code-plugin/blob/master/docs/features/secrets.adoc) in JCasC documentation for better security.
226243
227-
You can also configure `VaultGithubTokenCredential`, or `VautGCPCredential` or `VaultAppRoleCredential`
244+
You can also configure `VaultGithubTokenCredential`, `VaultGCPCredential`, `VaultAppRoleCredential` or `VaultAwsIamCredential`.
228245

229246
If you are unsure about how to do it from `yaml`. You can still use the UI to configure credentials.
230247
After you configured Credentials and the Global Vault configuration.
@@ -242,6 +259,8 @@ The secret source for JCasC is configured via environment variables as way to ge
242259
- The environment variable `CASC_VAULT_APPROLE` must be present, if token is not used and U/P not used. (Vault AppRole ID.)
243260
- The environment variable `CASC_VAULT_APPROLE_SECRET` must be present, it token is not used and U/P not used. (Vault AppRole Secret ID.)
244261
- The environment variable `CASC_VAULT_KUBERNETES_ROLE` must be present, if you want to use Kubernetes Service Account. (Vault Kubernetes Role.)
262+
- The environment variable `CASC_VAULT_AWS_IAM_ROLE` must be present, if you want to use AWS IAM authentiation. (Vault AWS IAM Role.)
263+
- The environment variable `CASC_VAULT_AWS_IAM_SERVER_ID` must be present when using AWS IAM authentication and the Vault auth method requires a value for the `X-Vault-AWS-IAM-Server-ID` header. (Vault AWS IAM Server ID.)
245264
- The environment variable `CASC_VAULT_TOKEN` must be present, if U/P is not used. (Vault token.)
246265
- The environment variable `CASC_VAULT_PATHS` must be present. (Comma separated vault key paths. For example, `secret/jenkins,secret/admin`.)
247266
- The environment variable `CASC_VAULT_URL` must be present. (Vault url, including port number.)
@@ -253,7 +272,7 @@ The secret source for JCasC is configured via environment variables as way to ge
253272
- The environment variable `CASC_VAULT_ENGINE_VERSION` is optional. If unset, your vault path is assumed to be using kv version 2. If your vault path uses engine version 1, set this variable to `1`.
254273
- The issued token should have read access to vault path `auth/token/lookup-self` in order to determine its expiration time. JCasC will re-issue a token if its expiration is reached (except for `CASC_VAULT_TOKEN`).
255274

256-
If the environment variables `CASC_VAULT_URL` and `CASC_VAULT_PATHS` are present, JCasC will try to gather initial secrets from Vault. However for it to work properly there is a need for authentication by either the combination of `CASC_VAULT_USER` and `CASC_VAULT_PW`, a `CASC_VAULT_TOKEN`, the combination of `CASC_VAULT_APPROLE` and `CASC_VAULT_APPROLE_SECRET`, or a `CASC_VAULT_KUBERNETES_ROLE`. The authenticated user must have at least read access.
275+
If the environment variables `CASC_VAULT_URL` and `CASC_VAULT_PATHS` are present, JCasC will try to gather initial secrets from Vault. However for it to work properly there is a need for authentication by either the combination of `CASC_VAULT_USER` and `CASC_VAULT_PW`, a `CASC_VAULT_TOKEN`, the combination of `CASC_VAULT_APPROLE` and `CASC_VAULT_APPROLE_SECRET`, a `CASC_VAULT_KUBERNETES_ROLE`, or a `CASC_VAULT_AWS_IAM_ROLE`. The authenticated user must have at least read access.
257276

258277
You can also provide a `CASC_VAULT_FILE` environment variable where you load the secrets from a file.
259278

Diff for: docs/images/aws_iam_credential.png

77.5 KB
Loading

Diff for: pom.xml

+12
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@
139139
<artifactId>configuration-as-code</artifactId>
140140
<optional>true</optional>
141141
</dependency>
142+
<dependency>
143+
<groupId>org.jenkins-ci.plugins</groupId>
144+
<artifactId>aws-java-sdk</artifactId>
145+
<version>1.11.955</version>
146+
<optional>true</optional>
147+
<exclusions>
148+
<exclusion>
149+
<groupId>joda-time</groupId>
150+
<artifactId>joda-time</artifactId>
151+
</exclusion>
152+
</exclusions>
153+
</dependency>
142154
<!-- Test Dependencies -->
143155
<dependency>
144156
<groupId>io.jenkins.configuration-as-code</groupId>
+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.datapipe.jenkins.vault;
2+
3+
import com.amazonaws.DefaultRequest;
4+
import com.amazonaws.auth.AWS4Signer;
5+
import com.amazonaws.auth.AWSCredentials;
6+
import com.amazonaws.auth.DefaultAWSCredentialsProviderChain;
7+
import com.amazonaws.http.HttpMethodName;
8+
import com.amazonaws.util.RuntimeHttpUtils;
9+
import com.bettercloud.vault.VaultException;
10+
import com.bettercloud.vault.api.Auth;
11+
import com.bettercloud.vault.json.JsonArray;
12+
import com.bettercloud.vault.json.JsonObject;
13+
import com.datapipe.jenkins.vault.exception.VaultPluginException;
14+
import edu.umd.cs.findbugs.annotations.CheckForNull;
15+
import edu.umd.cs.findbugs.annotations.NonNull;
16+
import hudson.Util;
17+
import java.io.ByteArrayInputStream;
18+
import java.io.IOException;
19+
import java.net.URI;
20+
import java.net.URISyntaxException;
21+
import java.net.URL;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.Base64;
24+
import java.util.Map;
25+
import java.util.logging.Level;
26+
import java.util.logging.Logger;
27+
import org.apache.commons.io.IOUtils;
28+
import org.apache.commons.lang.StringUtils;
29+
30+
public class AwsHelper {
31+
32+
private final static Logger LOGGER = Logger.getLogger(AwsHelper.class.getName());
33+
34+
@NonNull
35+
public static String getToken(@NonNull Auth auth, @CheckForNull AWSCredentials credentials,
36+
@CheckForNull String role, @CheckForNull String serverIdValue,
37+
@CheckForNull String mountPath) throws VaultPluginException {
38+
final EncodedIdentityRequest request;
39+
try {
40+
request = new EncodedIdentityRequest(credentials, serverIdValue);
41+
} catch (IOException | URISyntaxException e) {
42+
throw new VaultPluginException("could not get IAM request from AWS metadata", e);
43+
}
44+
45+
// Convert empty role and mount to null so loginByAwsIam uses the defaults
46+
final String requestRole = Util.fixEmptyAndTrim(role);
47+
final String requestMountPath = Util.fixEmptyAndTrim(mountPath);
48+
try {
49+
return auth.loginByAwsIam(requestRole, request.encodedUrl, request.encodedBody,
50+
request.encodedHeaders, requestMountPath)
51+
.getAuthClientToken();
52+
} catch (VaultException e) {
53+
throw new VaultPluginException("could not log in into vault", e);
54+
}
55+
}
56+
57+
private static class EncodedIdentityRequest {
58+
59+
@NonNull
60+
public final String encodedHeaders;
61+
62+
@NonNull
63+
public final String encodedBody;
64+
65+
@NonNull
66+
public final String encodedUrl;
67+
68+
private static final String data = "Action=GetCallerIdentity&Version=2011-06-15";
69+
private static final String endpoint = "https://sts.amazonaws.com";
70+
71+
EncodedIdentityRequest(@CheckForNull AWSCredentials credentials, @CheckForNull String serverIdValue) throws IOException, URISyntaxException {
72+
LOGGER.fine("Creating GetCallerIdentity request");
73+
final DefaultRequest request = new DefaultRequest("sts");
74+
request.addHeader("Content-Type", "application/x-www-form-urlencoded; charset=utf-8");
75+
if (StringUtils.isNotEmpty(serverIdValue)) {
76+
request.addHeader("X-Vault-AWS-IAM-Server-ID", serverIdValue);
77+
}
78+
request.setContent(new ByteArrayInputStream(this.data.getBytes(StandardCharsets.UTF_8)));
79+
request.setHttpMethod(HttpMethodName.POST);
80+
request.setEndpoint(new URI(this.endpoint));
81+
82+
if (credentials == null) {
83+
LOGGER.fine("Acquiring AWS credentials");
84+
credentials = new DefaultAWSCredentialsProviderChain().getCredentials();
85+
LOGGER.log(Level.FINER, "AWS Access Key ID: {0}", credentials.getAWSAccessKeyId());
86+
}
87+
88+
LOGGER.fine("Signing GetCallerIdentity request");
89+
final AWS4Signer aws4Signer = new AWS4Signer();
90+
aws4Signer.setServiceName(request.getServiceName());
91+
aws4Signer.sign(request, credentials);
92+
93+
final Base64.Encoder encoder = Base64.getEncoder();
94+
95+
final JsonObject headers = new JsonObject();
96+
final Map<String, String> headersMap = getHeadersMap(request);
97+
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
98+
final JsonArray array = new JsonArray();
99+
array.add(entry.getValue());
100+
headers.add(entry.getKey(), array);
101+
}
102+
encodedHeaders = encoder.encodeToString(headers.toString().getBytes(StandardCharsets.UTF_8));
103+
104+
final byte[] body = IOUtils.toByteArray(request.getContent());
105+
encodedBody = encoder.encodeToString(body);
106+
107+
final URL url = RuntimeHttpUtils.convertRequestToUrl(request, true, true);
108+
encodedUrl = encoder.encodeToString(url.toString().getBytes(StandardCharsets.UTF_8));
109+
}
110+
111+
// DefaultRequest.getHeaders() really returns a Map<String,String>, but for some reason it
112+
// comes back as a bare Map
113+
@SuppressWarnings("unchecked")
114+
private static Map<String, String> getHeadersMap(DefaultRequest request) {
115+
return request.getHeaders();
116+
}
117+
}
118+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.datapipe.jenkins.vault.credentials;
2+
3+
import com.bettercloud.vault.api.Auth;
4+
import com.cloudbees.plugins.credentials.CredentialsScope;
5+
import com.datapipe.jenkins.vault.AwsHelper;
6+
import edu.umd.cs.findbugs.annotations.CheckForNull;
7+
import edu.umd.cs.findbugs.annotations.NonNull;
8+
import hudson.Extension;
9+
import org.kohsuke.stapler.DataBoundConstructor;
10+
import org.kohsuke.stapler.DataBoundSetter;
11+
12+
import static org.apache.commons.lang.StringUtils.defaultIfBlank;
13+
14+
public class VaultAwsIamCredential extends AbstractAuthenticatingVaultTokenCredential {
15+
16+
@NonNull
17+
private String role = "";
18+
19+
@NonNull
20+
private String serverId = "";
21+
22+
@NonNull
23+
private String mountPath = DescriptorImpl.defaultMountPath;
24+
25+
@DataBoundConstructor
26+
public VaultAwsIamCredential(@CheckForNull CredentialsScope scope, @CheckForNull String id,
27+
@CheckForNull String description) {
28+
super(scope, id, description);
29+
}
30+
31+
@NonNull
32+
public String getRole() {
33+
return this.role;
34+
}
35+
36+
@DataBoundSetter
37+
public void setRole(@NonNull String role) {
38+
this.role = role;
39+
}
40+
41+
@NonNull
42+
public String getServerId() {
43+
return this.serverId;
44+
}
45+
46+
@DataBoundSetter
47+
public void setServerId(@NonNull String serverId) {
48+
this.serverId = serverId;
49+
}
50+
51+
@NonNull
52+
public String getMountPath() {
53+
return this.mountPath;
54+
}
55+
56+
@DataBoundSetter
57+
public void setMountPath(@NonNull String mountPath) {
58+
this.mountPath = defaultIfBlank(mountPath, DescriptorImpl.defaultMountPath);
59+
}
60+
61+
@Override
62+
public String getToken(Auth auth) {
63+
return AwsHelper.getToken(auth, null, this.role, this.serverId, this.mountPath);
64+
}
65+
66+
@Extension
67+
public static class DescriptorImpl extends BaseStandardCredentialsDescriptor {
68+
@NonNull
69+
@Override
70+
public String getDisplayName() {
71+
return "Vault AWS IAM Credential";
72+
}
73+
74+
public static final String defaultMountPath = "aws";
75+
}
76+
}

Diff for: src/main/java/com/datapipe/jenkins/vault/jcasc/secrets/VaultAuthenticator.java

+3
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ static VaultAuthenticator of(VaultUsernamePassword vaultUsernamePassword, String
1818
static VaultAuthenticator of(VaultKubernetes vaultKubernetes, String mountPath) {
1919
return new VaultKubernetesAuthenticator(vaultKubernetes, mountPath);
2020
}
21+
static VaultAuthenticator of(VaultAwsIam vaultAwsIam, String mountPath) {
22+
return new VaultAwsIamAuthenticator(vaultAwsIam, mountPath);
23+
}
2124
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.datapipe.jenkins.vault.jcasc.secrets;
2+
3+
import edu.umd.cs.findbugs.annotations.NonNull;
4+
import java.util.Objects;
5+
import org.kohsuke.accmod.Restricted;
6+
import org.kohsuke.accmod.restrictions.ProtectedExternally;
7+
8+
@Restricted(ProtectedExternally.class)
9+
public class VaultAwsIam {
10+
11+
@NonNull
12+
private String role;
13+
14+
@NonNull
15+
private String serverId;
16+
17+
public VaultAwsIam(@NonNull String role, @NonNull String serverId) {
18+
this.role = role;
19+
this.serverId = serverId;
20+
}
21+
22+
@NonNull
23+
public String getRole() {
24+
return role;
25+
}
26+
27+
@NonNull
28+
public String getServerId() {
29+
return serverId;
30+
}
31+
32+
@Override
33+
public int hashCode() {
34+
return Objects.hash(role, serverId);
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.datapipe.jenkins.vault.jcasc.secrets;
2+
3+
import com.bettercloud.vault.Vault;
4+
import com.bettercloud.vault.VaultConfig;
5+
import com.bettercloud.vault.VaultException;
6+
import com.datapipe.jenkins.vault.AwsHelper;
7+
import com.datapipe.jenkins.vault.exception.VaultPluginException;
8+
import edu.umd.cs.findbugs.annotations.NonNull;
9+
import java.util.Objects;
10+
import java.util.logging.Level;
11+
import java.util.logging.Logger;
12+
13+
public class VaultAwsIamAuthenticator extends VaultAuthenticatorWithExpiration {
14+
private final static Logger LOGGER = Logger.getLogger(VaultAwsIamAuthenticator.class.getName());
15+
16+
@NonNull
17+
private VaultAwsIam awsIam;
18+
19+
@NonNull
20+
private String mountPath;
21+
22+
public VaultAwsIamAuthenticator(@NonNull VaultAwsIam awsIam, @NonNull String mountPath) {
23+
this.awsIam = awsIam;
24+
this.mountPath = mountPath;
25+
}
26+
27+
public void authenticate(@NonNull Vault vault, @NonNull VaultConfig config) throws VaultException, VaultPluginException {
28+
if (isTokenTTLExpired()) {
29+
// authenticate
30+
currentAuthToken = AwsHelper.getToken(vault.auth(), null, awsIam.getRole(), awsIam.getServerId(), mountPath);
31+
config.token(currentAuthToken).build();
32+
LOGGER.log(Level.FINE, "Login to Vault using AWS IAM successful");
33+
getTTLExpiryOfCurrentToken(vault);
34+
} else {
35+
// make sure current auth token is set in config
36+
config.token(currentAuthToken).build();
37+
}
38+
}
39+
40+
@Override
41+
public boolean equals(Object o) {
42+
return super.equals(o);
43+
}
44+
45+
@Override
46+
public int hashCode() {
47+
return Objects.hash(awsIam);
48+
}
49+
}

0 commit comments

Comments
 (0)