fluidattacks_core_git/
aws.rs1use anyhow::{bail, Context, Result};
2use regex::Regex;
3use uuid::Uuid;
4
5pub struct AwsTempCreds {
6 pub access_key: String,
7 pub secret_key: String,
8 pub session_token: String,
9 pub region: String,
10}
11
12struct Hop<'a> {
13 role_arn: &'a str,
14 external_id: Option<&'a str>,
15 policy: Option<&'static str>,
16}
17
18const ONLY_ASSUME_ROLE: &str = r#"{
22 "Version": "2012-10-17",
23 "Statement": [{
24 "Effect": "Allow",
25 "Action": "sts:AssumeRole",
26 "Resource": "*"
27 }]
28}"#;
29
30const fn chain<'a>(
32 arn: &'a str,
33 external_id: &'a str,
34 via: Option<&'a str>,
35) -> (Hop<'a>, Option<Hop<'a>>) {
36 let client = Hop {
37 role_arn: arn,
38 external_id: Some(external_id),
39 policy: None,
40 };
41
42 match via {
43 Some(intermediate) => (
44 Hop {
45 role_arn: intermediate,
46 external_id: None,
47 policy: Some(ONLY_ASSUME_ROLE),
48 },
49 Some(client),
50 ),
51 None => (client, None),
52 }
53}
54
55pub async fn assume_role(
56 arn: &str,
57 external_id: &str,
58 url: &str,
59 via: Option<&str>,
60) -> Result<AwsTempCreds> {
61 let region = extract_region(url)?;
62 let (first, then) = chain(arn, external_id, via);
63
64 let mut creds = assume(&first, ®ion, None).await?;
65 if let Some(hop) = then {
66 creds = assume(&hop, ®ion, Some(&creds)).await?;
67 }
68
69 Ok(creds)
70}
71
72async fn assume(hop: &Hop<'_>, region: &str, from: Option<&AwsTempCreds>) -> Result<AwsTempCreds> {
73 let mut config = aws_config::defaults(aws_config::BehaviorVersion::latest())
74 .region(aws_config::Region::new(region.to_owned()));
75
76 if let Some(session) = from {
77 config = config.credentials_provider(aws_sdk_sts::config::Credentials::new(
78 session.access_key.clone(),
79 session.secret_key.clone(),
80 Some(session.session_token.clone()),
81 None,
82 "fluidattacks-core-chain",
83 ));
84 }
85
86 let sts = aws_sdk_sts::Client::new(&config.load().await);
87
88 let mut request = sts
89 .assume_role()
90 .role_arn(hop.role_arn)
91 .role_session_name(format!("session-{}", Uuid::new_v4()));
92
93 if let Some(external_id) = hop.external_id {
94 request = request.external_id(external_id);
95 }
96
97 if let Some(policy) = hop.policy {
98 request = request.policy(policy);
99 }
100
101 let resp = request
102 .send()
103 .await
104 .with_context(|| format!("STS AssumeRole failed for {}", hop.role_arn))?;
105
106 let creds = resp
107 .credentials()
108 .ok_or_else(|| anyhow::anyhow!("no credentials in AssumeRole response"))?;
109
110 let access_key = creds.access_key_id().to_owned();
111 let secret_key = creds.secret_access_key().to_owned();
112 let session_token = creds.session_token().to_owned();
113
114 if access_key.is_empty() || secret_key.is_empty() {
115 bail!("empty credentials from STS AssumeRole");
116 }
117
118 Ok(AwsTempCreds {
119 access_key,
120 secret_key,
121 session_token,
122 region: region.to_owned(),
123 })
124}
125
126fn extract_region(url: &str) -> Result<String> {
127 let re = Regex::new(r"codecommit::([a-z0-9-]+)://")?;
128 if let Some(caps) = re.captures(url) {
129 return Ok(caps[1].to_string());
130 }
131
132 let re2 = Regex::new(r"git-codecommit\.([a-z0-9-]+)\.amazonaws\.com")?;
134 if let Some(caps) = re2.captures(url) {
135 return Ok(caps[1].to_string());
136 }
137
138 Ok("us-east-1".to_owned())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::chain;
144
145 #[test]
146 fn one_call_takes_the_client_role_with_its_external_id() {
147 let (first, then) = chain("arn:aws:iam::1:role/client", "an-external-id", None);
148
149 assert_eq!(first.role_arn, "arn:aws:iam::1:role/client");
150 assert_eq!(first.external_id, Some("an-external-id"));
151 assert_eq!(first.policy, None);
152 assert!(then.is_none());
153 }
154
155 #[test]
156 fn a_chain_takes_the_intermediate_role_first_and_without_the_external_id() {
157 let (first, then) = chain(
158 "arn:aws:iam::1:role/client",
159 "an-external-id",
160 Some("arn:aws:iam::2:role/intermediate"),
161 );
162
163 assert_eq!(first.role_arn, "arn:aws:iam::2:role/intermediate");
164 assert_eq!(first.external_id, None);
165
166 let client = then.expect("the client role is still taken");
167 assert_eq!(client.role_arn, "arn:aws:iam::1:role/client");
168 assert_eq!(client.external_id, Some("an-external-id"));
169 assert_eq!(client.policy, None);
170 }
171
172 #[test]
173 fn a_chain_lets_the_intermediate_session_only_assume_a_role() {
174 let (first, _) = chain(
175 "arn:aws:iam::1:role/client",
176 "an-external-id",
177 Some("arn:aws:iam::2:role/intermediate"),
178 );
179
180 let policy: serde_json::Value =
181 serde_json::from_str(first.policy.expect("the intermediate session is narrowed"))
182 .expect("the policy is json");
183 assert_eq!(
184 policy.get("Statement"),
185 Some(&serde_json::json!([{
186 "Effect": "Allow",
187 "Action": "sts:AssumeRole",
188 "Resource": "*",
189 }]))
190 );
191 }
192}