Skip to content
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
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.next.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ message = "Switch to [RustCrypto](https://github.com/RustCrypto)'s implementatio
references = ["smithy-rs#1404"]
meta = { "breaking" = false, "tada" = false, "bug" = false }
author = "petrosagg"

[[aws-sdk-rust]]
message = "Add support for `credential_process` in AWS configs for fetching credentials from an external process."
references = ["smithy-rs#1356"]
meta = { "breaking" = false, "tada" = true, "bug" = false }
author = "jszwedko"
230 changes: 230 additions & 0 deletions aws/rust-runtime/aws-config/src/credential_process.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/*
* 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::borrow::Cow;
use std::fmt;
use std::process::Command;
use std::time::SystemTime;

/// Credentials Provider
pub struct CredentialProcessProvider {
command: String,
}

/// Returns the given `command` string with arguments redacted if there were any
pub(crate) fn debug_fmt_command_string(command: &str) -> Cow<'_, str> {
match command.find(char::is_whitespace) {
Some(index) => Cow::Owned(format!("{} ** arguments redacted **", &command[0..index])),
None => Cow::Borrowed(command),
}
}

impl fmt::Debug for CredentialProcessProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Security: The arguments for command must be redacted since they can be sensitive
f.debug_struct("CredentialProcessProvider")
.field("command", &debug_fmt_command_string(&self.command))
.finish()
}
}

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`]
pub fn new(command: String) -> Self {
Self { command }
}

async fn credentials(&self) -> credentials::Result {
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]);
command
} else {
let mut command = Command::new("sh");
command.args(&["-c", &self.command]);
command
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't need the if windows / else since std::process::Command::new will use the PATH to find the executable if an absolute path wasn't given: https://doc.rust-lang.org/stable/std/process/struct.Command.html#method.new

The SDKs don't support things like shell aliases or argument variable substitution, so it shouldn't run through a shell.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly, this is the way the Go V2 SDK and Java V2 SDKs are doing it. Boto3 looks like it does its own platform-based splitting, with its own implementation for Windows and using shlex for other platforms. I'll dig into this some more to figure out what we should do for Rust.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not going to block on this since Go V2 and Java V2 are doing it this way.


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, but must be redacted at debug level
// since they can contain sensitive information.
tracing::trace!(command = ?command, status = ?output.status, "executed command (unredacted)");
tracing::debug!(command = ?debug_fmt_command_string(&self.command), status = ?output.status, "executed command");

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) => {
let access_key_id =
access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as a minor nit for readability, I'd probably handle the error cases & abort in the match then have the bulk of the code be un-nested

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,
})
}
None => Err(InvalidJsonCredentials::MissingField("Version")),
Some(version) => Err(InvalidJsonCredentials::InvalidField {
field: "version",
err: format!("unknown version number: {}", version).into(),
}),
}
}

#[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")
)
);
}
}
6 changes: 3 additions & 3 deletions aws/rust-runtime/aws-config/src/http_credential_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use aws_types::credentials::CredentialsError;
use aws_types::{credentials, Credentials};

use crate::connector::expect_connector;
use crate::json_credentials::{parse_json_credentials, JsonCredentials};
use crate::json_credentials::{parse_json_credentials, JsonCredentials, RefreshableCredentials};
use crate::provider_config::ProviderConfig;

use bytes::Bytes;
Expand Down Expand Up @@ -145,12 +145,12 @@ impl ParseStrictResponse for CredentialsResponseParser {
std::str::from_utf8(response.body().as_ref()).map_err(CredentialsError::unhandled)?;
let json_creds = parse_json_credentials(str_resp).map_err(CredentialsError::unhandled)?;
match json_creds {
JsonCredentials::RefreshableCredentials {
JsonCredentials::RefreshableCredentials(RefreshableCredentials {
access_key_id,
secret_access_key,
session_token,
expiration,
} => Ok(Credentials::new(
}) => Ok(Credentials::new(
access_key_id,
secret_access_key,
Some(session_token.to_string()),
Expand Down
6 changes: 3 additions & 3 deletions aws/rust-runtime/aws-config/src/imds/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

use crate::imds;
use crate::imds::client::{ImdsError, LazyClient};
use crate::json_credentials::{parse_json_credentials, JsonCredentials};
use crate::json_credentials::{parse_json_credentials, JsonCredentials, RefreshableCredentials};
use crate::provider_config::ProviderConfig;
use aws_smithy_client::SdkError;
use aws_types::credentials::{future, CredentialsError, ProvideCredentials};
Expand Down Expand Up @@ -170,13 +170,13 @@ impl ImdsCredentialsProvider {
.await
.map_err(CredentialsError::provider_error)?;
match parse_json_credentials(&credentials) {
Ok(JsonCredentials::RefreshableCredentials {
Ok(JsonCredentials::RefreshableCredentials(RefreshableCredentials {
access_key_id,
secret_access_key,
session_token,
expiration,
..
}) => Ok(Credentials::new(
})) => Ok(Credentials::new(
access_key_id,
secret_access_key,
Some(session_token.to_string()),
Expand Down
Loading