use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use crate::service::make_service;
use crate::token::TemporaryToken;
use crate::{ClientOptions, CloudClient, RequestSigner, Result, RetryConfig, TokenProvider};
use self::credential::{AssumeRoleParams, AssumeRoleProvider, AwsAuthorizer, CredentialExt};
use crate::CredentialProvider;
mod builder;
pub(crate) mod credential;
pub use builder::*;
pub use credential::AwsCredential;
pub type AwsCredentialProvider = Arc<dyn CredentialProvider<Credential = AwsCredential>>;
#[derive(Debug, Clone)]
pub struct AmazonConfig {
pub region: String,
pub credentials: AwsCredentialProvider,
pub retry_config: RetryConfig,
pub client_options: ClientOptions,
pub skip_signature: bool,
}
impl AmazonConfig {
pub(crate) async fn get_credential(&self) -> Result<Option<Arc<AwsCredential>>> {
Ok(match self.skip_signature {
false => Some(self.credentials.get_credential().await?),
true => None,
})
}
}
impl RequestSigner for AmazonConfig {
fn sign<'a>(
&'a self,
req: reqwest::RequestBuilder,
) -> BoxFuture<'a, Result<reqwest::RequestBuilder>> {
Box::pin(async move {
if let Some(cred) = self.get_credential().await? {
let authorizer = AwsAuthorizer::new(&cred, "execute-api", &self.region);
Ok(req.with_aws_sigv4(Some(authorizer), None))
} else {
Ok(req)
}
})
}
}
pub const S3_GLOBAL_ENDPOINT: &str = "https://s3.amazonaws.com";
pub async fn bucket_region(
client: &CloudClient,
endpoint: &str,
bucket: &str,
) -> Result<Option<String>> {
let mut url = reqwest::Url::parse(endpoint).map_err(|source| crate::Error::Generic {
source: Box::new(source),
})?;
url.path_segments_mut()
.map_err(|()| crate::Error::Generic {
source: format!("endpoint `{endpoint}` cannot carry a path").into(),
})?
.pop_if_empty()
.push(bucket);
let request = client.reqwest_client.head(url).build()?;
let response = client.service.call(request).await?;
Ok(response
.headers()
.get("x-amz-bucket-region")
.and_then(|region| region.to_str().ok())
.filter(|region| !region.is_empty())
.map(str::to_owned))
}
pub async fn assume_role_with_base(
role_arn: &str,
region: &str,
sts_endpoint: Option<&str>,
policy: Option<String>,
base_credentials: AwsCredentialProvider,
) -> Result<TemporaryToken<Arc<AwsCredential>>> {
assume_role_with_base_and_options(
role_arn,
region,
sts_endpoint,
policy,
base_credentials,
&ClientOptions::default(),
)
.await
}
pub async fn assume_role_with_base_and_options(
role_arn: &str,
region: &str,
sts_endpoint: Option<&str>,
policy: Option<String>,
base_credentials: AwsCredentialProvider,
client_options: &ClientOptions,
) -> Result<TemporaryToken<Arc<AwsCredential>>> {
let endpoint = sts_endpoint
.map(|s| s.to_owned())
.unwrap_or_else(|| format!("https://sts.{region}.amazonaws.com"));
let provider = AssumeRoleProvider {
role_arn: role_arn.to_owned(),
session_name: DEFAULT_SESSION_NAME.to_owned(),
endpoint,
base_credentials,
region: region.to_owned(),
policy,
};
let client = client_options.client()?;
let service = make_service(client.clone(), None);
provider
.fetch_token(&client, &service, &RetryConfig::default())
.await
}
pub const DEFAULT_SESSION_NAME: &str = "TrestleVending";
#[derive(Debug, Clone, Default)]
pub struct AssumeRoleOptions {
pub duration: Option<Duration>,
pub session_name: Option<String>,
pub policy: Option<String>,
pub external_id: Option<String>,
pub source_identity: Option<String>,
pub tags: Vec<(String, String)>,
pub sts_endpoint: Option<String>,
pub client_options: ClientOptions,
pub retry: RetryConfig,
}
#[derive(Debug)]
pub struct AssumedRole {
pub credential: AwsCredential,
pub expiration: DateTime<Utc>,
}
pub async fn assume_role_with_options(
role_arn: &str,
region: &str,
base_credentials: AwsCredentialProvider,
options: &AssumeRoleOptions,
) -> Result<AssumedRole> {
let endpoint = options
.sts_endpoint
.clone()
.unwrap_or_else(|| format!("https://sts.{region}.amazonaws.com"));
let base = base_credentials
.get_credential()
.await
.map_err(|source| crate::Error::Generic {
source: Box::new(source),
})?;
let params = AssumeRoleParams {
role_arn,
session_name: options
.session_name
.as_deref()
.unwrap_or(DEFAULT_SESSION_NAME),
duration_secs: options.duration.map(|d| d.as_secs()),
policy: options.policy.as_deref(),
external_id: options.external_id.as_deref(),
source_identity: options.source_identity.as_deref(),
tags: &options.tags,
};
let client = options.client_options.client()?;
let service = make_service(client.clone(), None);
let (credential, expiration) = credential::assume_role_request(
&client,
&service,
&options.retry,
&base,
region,
&endpoint,
¶ms,
)
.await
.map_err(|source| crate::Error::Generic { source })?;
Ok(AssumedRole {
credential,
expiration,
})
}
pub async fn assume_role(
role_arn: &str,
region: &str,
sts_endpoint: Option<&str>,
policy: Option<String>,
) -> Result<TemporaryToken<Arc<AwsCredential>>> {
let base_credentials = AmazonBuilder::from_env()
.with_region(region)
.build(None)?
.credentials;
assume_role_with_base(role_arn, region, sts_endpoint, policy, base_credentials).await
}
#[cfg(test)]
mod tests {
use mockito::Matcher;
use super::*;
fn client() -> CloudClient {
CloudClient::new_unauthenticated_with_options(
&ClientOptions::new().with_allow_http(true),
RetryConfig::default(),
None,
)
.unwrap()
}
#[tokio::test]
async fn bucket_region_is_read_from_any_status() {
let mut server = mockito::Server::new_async().await;
for (bucket, status, region) in [
("open", 200, "us-east-1"),
("elsewhere", 301, "eu-west-1"),
("private.with.dots", 403, "ap-south-1"),
] {
let mock = server
.mock("HEAD", format!("/{bucket}").as_str())
.match_header("authorization", Matcher::Missing)
.with_status(status)
.with_header("x-amz-bucket-region", region)
.create_async()
.await;
let found = bucket_region(&client(), &server.url(), bucket)
.await
.unwrap();
assert_eq!(found.as_deref(), Some(region), "{bucket}");
mock.assert_async().await;
}
}
#[tokio::test]
async fn bucket_region_is_none_without_the_header() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("HEAD", "/missing")
.with_status(404)
.create_async()
.await;
let found = bucket_region(&client(), &server.url(), "missing")
.await
.unwrap();
assert_eq!(found, None);
}
const ASSUME_ROLE_RESPONSE: &str = r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>ASIASESSION</AccessKeyId>
<SecretAccessKey>session-secret</SecretAccessKey>
<SessionToken>session-token</SessionToken>
<Expiration>2030-01-01T00:15:00Z</Expiration>
</Credentials>
</AssumeRoleResult>
</AssumeRoleResponse>"#;
fn base() -> AwsCredentialProvider {
Arc::new(crate::StaticCredentialProvider::new(AwsCredential {
key_id: "AKIDBASE".into(),
secret_key: "base-secret".into(),
token: None,
}))
}
fn options(server: &mockito::Server) -> AssumeRoleOptions {
AssumeRoleOptions {
sts_endpoint: Some(server.url()),
client_options: ClientOptions::new().with_allow_http(true),
..AssumeRoleOptions::default()
}
}
#[tokio::test]
async fn assume_role_sends_every_option() {
let mut server = mockito::Server::new_async().await;
let sts = server
.mock("POST", "/")
.match_header(
"authorization",
Matcher::Regex("^AWS4-HMAC-SHA256 Credential=AKIDBASE/".into()),
)
.match_body(Matcher::AllOf(vec![
Matcher::UrlEncoded("Action".into(), "AssumeRole".into()),
Matcher::UrlEncoded("RoleArn".into(), "arn:aws:iam::1:role/vend".into()),
Matcher::UrlEncoded("DurationSeconds".into(), "900".into()),
Matcher::UrlEncoded("RoleSessionName".into(), "alice@table".into()),
Matcher::UrlEncoded("ExternalId".into(), "ext-42".into()),
Matcher::UrlEncoded("SourceIdentity".into(), "alice".into()),
Matcher::UrlEncoded("Policy".into(), r#"{"Version":"2012-10-17"}"#.into()),
Matcher::UrlEncoded("Tags.member.1.Key".into(), "table".into()),
Matcher::UrlEncoded("Tags.member.1.Value".into(), "main.default.t".into()),
Matcher::UrlEncoded("Tags.member.2.Key".into(), "env".into()),
]))
.with_body(ASSUME_ROLE_RESPONSE)
.create_async()
.await;
let options = AssumeRoleOptions {
duration: Some(Duration::from_secs(900)),
session_name: Some("alice@table".into()),
policy: Some(r#"{"Version":"2012-10-17"}"#.into()),
external_id: Some("ext-42".into()),
source_identity: Some("alice".into()),
tags: vec![
("table".into(), "main.default.t".into()),
("env".into(), "dev".into()),
],
..options(&server)
};
let role =
assume_role_with_options("arn:aws:iam::1:role/vend", "us-east-1", base(), &options)
.await
.unwrap();
assert_eq!(role.credential.key_id, "ASIASESSION");
assert_eq!(role.credential.token.as_deref(), Some("session-token"));
assert_eq!(role.expiration.to_rfc3339(), "2030-01-01T00:15:00+00:00");
sts.assert_async().await;
}
#[tokio::test]
async fn assume_role_defaults() {
let mut server = mockito::Server::new_async().await;
let body = Arc::new(std::sync::Mutex::new(String::new()));
let seen = Arc::clone(&body);
let sts = server
.mock("POST", "/")
.with_body_from_request(move |request| {
*seen.lock().unwrap() =
String::from_utf8_lossy(request.body().unwrap()).into_owned();
ASSUME_ROLE_RESPONSE.into()
})
.create_async()
.await;
assume_role_with_options(
"arn:aws:iam::1:role/vend",
"us-east-1",
base(),
&options(&server),
)
.await
.unwrap();
sts.assert_async().await;
let body = body.lock().unwrap();
assert!(body.contains("DurationSeconds=3600"), "{body}");
assert!(
body.contains(&format!("RoleSessionName={DEFAULT_SESSION_NAME}")),
"{body}"
);
for absent in ["ExternalId", "SourceIdentity", "Tags", "Policy"] {
assert!(!body.contains(absent), "{absent} in {body}");
}
}
}