-
Notifications
You must be signed in to change notification settings - Fork 180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for credential_process
from profiles
#1356
Merged
jdisanti
merged 18 commits into
smithy-lang:main
from
jszwedko:jszwedko/add-support-for-credential-process
May 26, 2022
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
c3a595c
Starting to add support for `credential_process` from profiles
jszwedko f8b05ad
Remove dbg statement
jszwedko 14abfdd
Re-order credential_process later to match boto ordering
jszwedko 14ad7a9
Avoid parsing the command
jszwedko b5f1c6b
correct provider name
jszwedko 865a6ee
Remove no longer needed dependency
jszwedko a787b61
Add failing test case for credential_process
jszwedko 7de0201
Add JSON parsing for credential_process responses
jszwedko 2e0ae8f
Add test
jszwedko 0e4f34c
Update changelog
jszwedko 6433a24
Merge branch 'main' into jszwedko/add-support-for-credential-process
jszwedko 9f88a6b
Merge branch 'main' into jszwedko/add-support-for-credential-process
jdisanti 16a8495
Merge branch 'main' into jszwedko/add-support-for-credential-process
jdisanti ce75253
Fix clippy lints
jdisanti 7b867af
Incorporate feedback
jdisanti 3be6523
Add more tests and refactor redaction
jdisanti 2a888d4
Merge remote-tracking branch 'upstream/main' into jszwedko/add-suppor…
jdisanti 652345d
Add more documentation to `CredentialProcessProvider`
jdisanti File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,292 @@ | ||
/* | ||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
//! Credentials Provider for external process | ||
|
||
use crate::json_credentials::{json_parse_loop, InvalidJsonCredentials, RefreshableCredentials}; | ||
use aws_smithy_json::deserialize::Token; | ||
use aws_smithy_types::date_time::Format; | ||
use aws_smithy_types::DateTime; | ||
use aws_types::credentials::{future, CredentialsError, ProvideCredentials}; | ||
use aws_types::{credentials, Credentials}; | ||
use std::fmt; | ||
use std::process::Command; | ||
use std::time::SystemTime; | ||
|
||
pub(crate) struct CommandWithSensitiveArgs<T>(T); | ||
|
||
impl<T> CommandWithSensitiveArgs<T> | ||
where | ||
T: AsRef<str>, | ||
{ | ||
pub(crate) fn new(value: T) -> Self { | ||
Self(value) | ||
} | ||
|
||
pub(crate) fn unredacted(&self) -> &str { | ||
self.0.as_ref() | ||
} | ||
} | ||
|
||
impl<T> fmt::Display for CommandWithSensitiveArgs<T> | ||
where | ||
T: AsRef<str>, | ||
{ | ||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
// Security: The arguments for command must be redacted since they can be sensitive | ||
let command = self.0.as_ref(); | ||
match command.find(char::is_whitespace) { | ||
Some(index) => write!(f, "{} ** arguments redacted **", &command[0..index]), | ||
None => write!(f, "{}", command), | ||
} | ||
} | ||
} | ||
|
||
impl<T> fmt::Debug for CommandWithSensitiveArgs<T> | ||
where | ||
T: AsRef<str>, | ||
{ | ||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
write!(f, "{:?}", format!("{}", self)) | ||
} | ||
} | ||
|
||
impl<T> Clone for CommandWithSensitiveArgs<T> | ||
where | ||
T: Clone, | ||
{ | ||
fn clone(&self) -> Self { | ||
Self(self.0.clone()) | ||
} | ||
} | ||
|
||
/// External process credentials provider | ||
/// | ||
/// This credentials provider runs a configured external process and parses | ||
/// its output to retrieve credentials. | ||
/// | ||
/// The external process must exit with status 0 and output the following | ||
/// JSON format to `stdout` to provide credentials: | ||
/// | ||
/// ```json | ||
/// { | ||
/// "Version:" 1, | ||
/// "AccessKeyId": "access key id", | ||
/// "SecretAccessKey": "secret access key", | ||
/// "SessionToken": "session token", | ||
/// "Expiration": "time that the expiration will expire" | ||
/// } | ||
/// ``` | ||
/// | ||
/// The `Version` must be set to 1. `AccessKeyId` and `SecretAccessKey` are always required. | ||
/// `SessionToken` must be set if a session token is associated with the `AccessKeyId`. | ||
/// The `Expiration` is optional, and must be given in the RFC 3339 date time format (e.g., | ||
/// `2022-05-26T12:34:56.789Z`). | ||
/// | ||
/// If the external process exits with a non-zero status, then the contents of `stderr` | ||
/// will be output as part of the credentials provider error message. | ||
/// | ||
/// This credentials provider is included in the profile credentials provider, and can be | ||
/// configured using the `credential_process` attribute. For example: | ||
/// | ||
/// ```plain | ||
/// [profile example] | ||
/// credential_process = /path/to/my/process --some --arguments | ||
/// ``` | ||
#[derive(Debug)] | ||
pub struct CredentialProcessProvider { | ||
command: CommandWithSensitiveArgs<String>, | ||
} | ||
|
||
impl ProvideCredentials for CredentialProcessProvider { | ||
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a> | ||
where | ||
Self: 'a, | ||
{ | ||
future::ProvideCredentials::new(self.credentials()) | ||
} | ||
} | ||
|
||
impl CredentialProcessProvider { | ||
/// Create new [`CredentialProcessProvider`] with the `command` needed to execute the external process. | ||
pub fn new(command: String) -> Self { | ||
Self { | ||
command: CommandWithSensitiveArgs::new(command), | ||
} | ||
} | ||
|
||
async fn credentials(&self) -> credentials::Result { | ||
// Security: command arguments must be redacted at debug level | ||
tracing::debug!(command = %self.command, "loading credentials from external process"); | ||
|
||
let mut command = if cfg!(windows) { | ||
let mut command = Command::new("cmd.exe"); | ||
command.args(&["/C", self.command.unredacted()]); | ||
command | ||
} else { | ||
let mut command = Command::new("sh"); | ||
command.args(&["-c", self.command.unredacted()]); | ||
command | ||
}; | ||
|
||
let output = command.output().map_err(|e| { | ||
CredentialsError::provider_error(format!( | ||
"Error retrieving credentials from external process: {}", | ||
e | ||
)) | ||
})?; | ||
|
||
// Security: command arguments can be logged at trace level | ||
tracing::trace!(command = ?command, status = ?output.status, "executed command (unredacted)"); | ||
|
||
if !output.status.success() { | ||
let reason = | ||
std::str::from_utf8(&output.stderr).unwrap_or("could not decode stderr as UTF-8"); | ||
return Err(CredentialsError::provider_error(format!( | ||
"Error retrieving credentials: external process exited with code {}. Stderr: {}", | ||
output.status, reason | ||
))); | ||
} | ||
|
||
let output = std::str::from_utf8(&output.stdout).map_err(|e| { | ||
CredentialsError::provider_error(format!( | ||
"Error retrieving credentials from external process: could not decode output as UTF-8: {}", | ||
e | ||
)) | ||
})?; | ||
|
||
match parse_credential_process_json_credentials(output) { | ||
Ok(RefreshableCredentials { | ||
access_key_id, | ||
secret_access_key, | ||
session_token, | ||
expiration, | ||
.. | ||
}) => Ok(Credentials::new( | ||
access_key_id, | ||
secret_access_key, | ||
Some(session_token.to_string()), | ||
expiration.into(), | ||
"CredentialProcess", | ||
)), | ||
Err(invalid) => Err(CredentialsError::provider_error(format!( | ||
"Error retrieving credentials from external process, could not parse response: {}", | ||
invalid | ||
))), | ||
} | ||
} | ||
} | ||
|
||
/// Deserialize a credential_process response from a string | ||
/// | ||
/// Returns an error if the response cannot be successfully parsed or is missing keys. | ||
/// | ||
/// Keys are case insensitive. | ||
pub(crate) fn parse_credential_process_json_credentials( | ||
credentials_response: &str, | ||
) -> Result<RefreshableCredentials, InvalidJsonCredentials> { | ||
let mut version = None; | ||
let mut access_key_id = None; | ||
let mut secret_access_key = None; | ||
let mut session_token = None; | ||
let mut expiration = None; | ||
json_parse_loop(credentials_response.as_bytes(), |key, value| { | ||
match (key, value) { | ||
/* | ||
"Version": 1, | ||
"AccessKeyId": "ASIARTESTID", | ||
"SecretAccessKey": "TESTSECRETKEY", | ||
"SessionToken": "TESTSESSIONTOKEN", | ||
"Expiration": "2022-05-02T18:36:00+00:00" | ||
*/ | ||
(key, Token::ValueNumber { value, .. }) if key.eq_ignore_ascii_case("Version") => { | ||
version = Some(value.to_i32()) | ||
} | ||
(key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccessKeyId") => { | ||
access_key_id = Some(value.to_unescaped()?) | ||
} | ||
(key, Token::ValueString { value, .. }) | ||
if key.eq_ignore_ascii_case("SecretAccessKey") => | ||
{ | ||
secret_access_key = Some(value.to_unescaped()?) | ||
} | ||
(key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("SessionToken") => { | ||
session_token = Some(value.to_unescaped()?) | ||
} | ||
(key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Expiration") => { | ||
expiration = Some(value.to_unescaped()?) | ||
} | ||
|
||
_ => {} | ||
}; | ||
Ok(()) | ||
})?; | ||
|
||
match version { | ||
Some(1) => { /* continue */ } | ||
None => return Err(InvalidJsonCredentials::MissingField("Version")), | ||
Some(version) => { | ||
return Err(InvalidJsonCredentials::InvalidField { | ||
field: "version", | ||
err: format!("unknown version number: {}", version).into(), | ||
}) | ||
} | ||
} | ||
|
||
let access_key_id = access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?; | ||
let secret_access_key = | ||
secret_access_key.ok_or(InvalidJsonCredentials::MissingField("SecretAccessKey"))?; | ||
let session_token = session_token.ok_or(InvalidJsonCredentials::MissingField("Token"))?; | ||
let expiration = expiration.ok_or(InvalidJsonCredentials::MissingField("Expiration"))?; | ||
let expiration = SystemTime::try_from( | ||
DateTime::from_str(expiration.as_ref(), Format::DateTime).map_err(|err| { | ||
InvalidJsonCredentials::InvalidField { | ||
field: "Expiration", | ||
err: err.into(), | ||
} | ||
})?, | ||
) | ||
.map_err(|_| { | ||
InvalidJsonCredentials::Other( | ||
"credential expiration time cannot be represented by a DateTime".into(), | ||
) | ||
})?; | ||
Ok(RefreshableCredentials { | ||
access_key_id, | ||
secret_access_key, | ||
session_token, | ||
expiration, | ||
}) | ||
} | ||
|
||
#[cfg(test)] | ||
mod test { | ||
use crate::credential_process::CredentialProcessProvider; | ||
use aws_smithy_types::date_time::Format; | ||
use aws_smithy_types::DateTime; | ||
use aws_types::credentials::ProvideCredentials; | ||
use std::time::SystemTime; | ||
|
||
#[tokio::test] | ||
async fn test_credential_process() { | ||
let provider = CredentialProcessProvider::new(String::from( | ||
r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "SessionToken": "TESTSESSIONTOKEN", "Expiration": "2022-05-02T18:36:00+00:00" }'"#, | ||
)); | ||
let creds = provider.provide_credentials().await.expect("valid creds"); | ||
assert_eq!(creds.access_key_id(), "ASIARTESTID"); | ||
assert_eq!(creds.secret_access_key(), "TESTSECRETKEY"); | ||
assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN")); | ||
assert_eq!( | ||
creds.expiry(), | ||
Some( | ||
SystemTime::try_from( | ||
DateTime::from_str("2022-05-02T18:36:00+00:00", Format::DateTime) | ||
.expect("static datetime") | ||
) | ||
.expect("static datetime") | ||
) | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is just
#[derive(Clone)]
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It needs the
where T: Clone
clause, but maybe I can just put that bound on the struct itself and then derive...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I take that approach, then each other trait
impl
needs to have aT: Clone
bound added, so I don't think it's much of a savings.