1use std::sync::Arc;
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use http::StatusCode;
6
7use fakecloud_aws::arn::Arn;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_core::validation::*;
10use fakecloud_persistence::SnapshotStore;
11
12use crate::evaluator::{
13 evaluate_resource_policy_only, Decision, EvalRequest, PolicyDocument, RequestContext,
14};
15use crate::persistence::{save_iam_snapshot, IamSnapshotLock};
16use crate::state::{CredentialIdentity, IamState, SharedIamState, StsTempCredential};
17use crate::xml_responses::{self, StsCredentials};
18use fakecloud_core::auth::{Principal, PrincipalType};
19
20const DEFAULT_ASSUME_ROLE_DURATION: i64 = 3600;
22
23const DEFAULT_SESSION_TOKEN_DURATION: i64 = 43200;
25
26const DEFAULT_FEDERATION_TOKEN_DURATION: i64 = 43200;
28
29fn compute_expiration_at(
31 req: &AwsRequest,
32 default_duration: i64,
33) -> Result<DateTime<Utc>, AwsServiceError> {
34 let duration = if let Some(ds) = req.query_params.get("DurationSeconds") {
35 ds.parse::<i64>().map_err(|_| {
36 AwsServiceError::aws_error(
37 StatusCode::BAD_REQUEST,
38 "ValidationError",
39 format!(
40 "Value '{}' at 'durationSeconds' failed to satisfy constraint: \
41 Member must be a valid integer",
42 ds
43 ),
44 )
45 })?
46 } else {
47 default_duration
48 };
49 Ok(Utc::now() + chrono::Duration::seconds(duration))
50}
51
52fn format_expiration(ts: DateTime<Utc>) -> String {
54 ts.format("%Y-%m-%dT%H:%M:%SZ").to_string()
55}
56
57#[cfg(test)]
60fn compute_expiration(req: &AwsRequest, default_duration: i64) -> Result<String, AwsServiceError> {
61 Ok(format_expiration(compute_expiration_at(
62 req,
63 default_duration,
64 )?))
65}
66
67pub struct StsService {
68 state: SharedIamState,
69 snapshot_store: Option<Arc<dyn SnapshotStore>>,
70 snapshot_lock: IamSnapshotLock,
71}
72
73mod assume;
74mod caller;
75mod federation;
76mod session;
77
78impl StsService {
79 pub fn new(state: SharedIamState) -> Self {
80 Self {
81 state,
82 snapshot_store: None,
83 snapshot_lock: crate::persistence::new_snapshot_lock(),
84 }
85 }
86
87 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
88 self.snapshot_store = Some(store);
89 self
90 }
91
92 pub fn with_snapshot_lock(mut self, lock: IamSnapshotLock) -> Self {
93 self.snapshot_lock = lock;
94 self
95 }
96}
97
98fn is_mutating_action(action: &str) -> bool {
101 matches!(
102 action,
103 "AssumeRole"
104 | "AssumeRoleWithWebIdentity"
105 | "AssumeRoleWithSAML"
106 | "GetSessionToken"
107 | "GetFederationToken"
108 | "AssumeRoot"
109 )
110}
111
112#[async_trait]
113impl AwsService for StsService {
114 fn service_name(&self) -> &str {
115 "sts"
116 }
117
118 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
119 let mutates = is_mutating_action(req.action.as_str());
120 let result = match req.action.as_str() {
121 "GetCallerIdentity" => self.get_caller_identity(&req),
122 "AssumeRole" => self.assume_role(&req),
123 "AssumeRoleWithWebIdentity" => self.assume_role_with_web_identity(&req),
124 "AssumeRoleWithSAML" => self.assume_role_with_saml(&req),
125 "GetSessionToken" => self.get_session_token(&req),
126 "GetFederationToken" => self.get_federation_token(&req),
127 "GetAccessKeyInfo" => self.get_access_key_info(&req),
128 "DecodeAuthorizationMessage" => self.decode_authorization_message(&req),
129 "AssumeRoot" => self.assume_root(&req),
130 "GetWebIdentityToken" => self.get_web_identity_token(&req),
131 "GetDelegatedAccessToken" => self.get_delegated_access_token(&req),
132 _ => Err(AwsServiceError::action_not_implemented("sts", &req.action)),
133 };
134 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
135 save_iam_snapshot(
136 &self.state,
137 self.snapshot_store.clone(),
138 &self.snapshot_lock,
139 )
140 .await;
141 }
142 result
143 }
144
145 fn supported_actions(&self) -> &[&str] {
146 &[
147 "GetCallerIdentity",
148 "AssumeRole",
149 "AssumeRoleWithWebIdentity",
150 "AssumeRoleWithSAML",
151 "GetSessionToken",
152 "GetFederationToken",
153 "GetAccessKeyInfo",
154 "DecodeAuthorizationMessage",
155 "AssumeRoot",
156 "GetWebIdentityToken",
157 "GetDelegatedAccessToken",
158 ]
159 }
160
161 fn iam_enforceable(&self) -> bool {
163 true
164 }
165
166 fn iam_action_for(
171 &self,
172 request: &fakecloud_core::service::AwsRequest,
173 ) -> Option<fakecloud_core::auth::IamAction> {
174 let action: &'static str = match request.action.as_str() {
175 "GetCallerIdentity" => "GetCallerIdentity",
176 "AssumeRole" => "AssumeRole",
177 "AssumeRoleWithWebIdentity" => "AssumeRoleWithWebIdentity",
178 "AssumeRoleWithSAML" => "AssumeRoleWithSAML",
179 "GetSessionToken" => "GetSessionToken",
180 "GetFederationToken" => "GetFederationToken",
181 "GetAccessKeyInfo" => "GetAccessKeyInfo",
182 "DecodeAuthorizationMessage" => "DecodeAuthorizationMessage",
183 "AssumeRoot" => "AssumeRoot",
184 "GetWebIdentityToken" => "GetWebIdentityToken",
185 "GetDelegatedAccessToken" => "GetDelegatedAccessToken",
186 _ => return None,
187 };
188 let resource = match action {
189 "AssumeRole" | "AssumeRoleWithWebIdentity" | "AssumeRoleWithSAML" => request
190 .query_params
191 .get("RoleArn")
192 .cloned()
193 .unwrap_or_else(|| "*".to_string()),
194 "AssumeRoot" => request
195 .query_params
196 .get("TargetPrincipal")
197 .cloned()
198 .unwrap_or_else(|| "*".to_string()),
199 _ => "*".to_string(),
200 };
201 Some(fakecloud_core::auth::IamAction {
202 service: "sts",
203 action,
204 resource,
205 })
206 }
207}
208
209pub(super) fn sts_issuer_url(region: &str) -> String {
213 let suffix = match partition_for_region(region) {
214 "aws-cn" => "amazonaws.com.cn",
215 _ => "amazonaws.com",
218 };
219 format!("https://sts.{region}.{suffix}")
220}
221
222fn partition_for_region(region: &str) -> &str {
224 if region.starts_with("cn-") {
225 "aws-cn"
226 } else if region.starts_with("us-iso-") {
227 "aws-iso"
228 } else if region.starts_with("us-isob-") {
229 "aws-iso-b"
230 } else if region.starts_with("us-isof-") {
231 "aws-iso-f"
232 } else if region.starts_with("eu-isoe-") {
233 "aws-iso-e"
234 } else {
235 "aws"
236 }
237}
238
239fn collect_session_policies(req: &AwsRequest, state: &IamState) -> Vec<String> {
247 let mut docs = Vec::new();
248 if let Some(inline) = req.query_params.get("Policy") {
249 docs.push(inline.clone());
250 }
251 for i in 1..=12 {
253 let key = format!("PolicyArns.member.{i}.arn");
254 let arn = match req.query_params.get(&key) {
255 Some(a) => a,
256 None => break,
257 };
258 match state
259 .policies
260 .get(arn.as_str())
261 .and_then(|p| {
262 p.versions
263 .iter()
264 .find(|v| v.is_default)
265 .or_else(|| p.versions.first())
266 })
267 .map(|v| v.document.clone())
268 {
269 Some(doc) => docs.push(doc),
270 None => {
271 tracing::debug!(
272 target: "fakecloud::iam::audit",
273 arn = %arn,
274 "PolicyArns entry does not resolve to a known managed policy; \
275 session will deny all actions covered by this entry"
276 );
277 docs.push(String::new());
278 }
279 }
280 }
281 docs
282}
283
284fn extract_access_key(req: &AwsRequest) -> Option<String> {
286 let auth = req.headers.get("authorization")?.to_str().ok()?;
287 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
288 Some(info.access_key)
289}
290
291fn sts_assume_root_error(msg: impl Into<String>) -> AwsServiceError {
297 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ExpiredTokenException", msg.into())
298}
299
300fn sts_web_identity_error(msg: impl Into<String>) -> AwsServiceError {
304 AwsServiceError::aws_error(
305 StatusCode::BAD_REQUEST,
306 "SessionDurationEscalationException",
307 msg.into(),
308 )
309}
310
311fn collect_audiences(req: &AwsRequest) -> Vec<String> {
315 let mut out = Vec::new();
316 for i in 1..=50 {
317 let key = format!("Audience.member.{i}");
318 match req.query_params.get(&key) {
319 Some(v) => out.push(v.clone()),
320 None => break,
321 }
322 }
323 out
324}
325
326fn extract_account_from_arn(arn: &str) -> Option<String> {
328 let parts: Vec<&str> = arn.split(':').collect();
329 if parts.len() >= 5 && !parts[4].is_empty() {
330 Some(parts[4].to_string())
331 } else {
332 None
333 }
334}
335
336#[derive(Debug, Clone, Default)]
341struct SamlClaims {
342 issuer: Option<String>,
343 audience: Option<String>,
344}
345
346fn extract_saml_claims(saml_b64: &str) -> SamlClaims {
350 use base64::Engine;
351 let mut claims = SamlClaims::default();
352 let decoded = match base64::engine::general_purpose::STANDARD.decode(saml_b64) {
353 Ok(b) => b,
354 Err(_) => return claims,
355 };
356 let xml_str = match String::from_utf8(decoded) {
357 Ok(s) => s,
358 Err(_) => return claims,
359 };
360 claims.issuer = extract_xml_text_after(&xml_str, "Issuer");
361 claims.audience = extract_xml_text_after(&xml_str, "Audience");
362 claims
363}
364
365fn extract_xml_text_after(xml: &str, local_name: &str) -> Option<String> {
370 let mut search_from = 0;
374 while let Some(idx) = xml[search_from..].find('<') {
375 let abs = search_from + idx;
376 let after_lt = &xml[abs + 1..];
377 let tag_start = after_lt
379 .split_once(':')
380 .map(|(_pfx, rest)| rest)
381 .unwrap_or(after_lt);
382 if let Some(after_name) = tag_start.strip_prefix(local_name) {
383 let valid_terminator = after_name
385 .chars()
386 .next()
387 .map(|c| c == '>' || c == ' ' || c == '/' || c == '\t' || c == '\n')
388 .unwrap_or(false);
389 if valid_terminator {
390 let gt_pos = after_lt.find('>')?;
391 let content_start = abs + 1 + gt_pos + 1;
392 let next_lt = xml[content_start..].find('<')?;
393 let value = xml[content_start..content_start + next_lt].trim();
394 if !value.is_empty() {
395 return Some(value.to_string());
396 }
397 }
398 }
399 search_from = abs + 1;
400 }
401 None
402}
403
404#[derive(Debug, Clone, Default)]
409struct JwtClaims {
410 iss: Option<String>,
411 aud: Vec<String>,
416 sub: Option<String>,
417 raw: serde_json::Map<String, serde_json::Value>,
418}
419
420fn decode_jwt(token: &str) -> Option<JwtClaims> {
425 use base64::Engine;
426 let segments: Vec<&str> = token.split('.').collect();
427 if segments.len() != 3 {
428 return None;
429 }
430 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
431 .decode(segments[1])
432 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(segments[1]))
433 .ok()?;
434 let json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
435 let map = json.as_object()?.clone();
436 let str_field = |k: &str| map.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
437 let aud = match map.get("aud") {
441 Some(serde_json::Value::String(s)) => vec![s.clone()],
442 Some(serde_json::Value::Array(arr)) => arr
443 .iter()
444 .filter_map(|v| v.as_str().map(|s| s.to_string()))
445 .collect(),
446 _ => Vec::new(),
447 };
448 Some(JwtClaims {
449 iss: str_field("iss"),
450 aud,
451 sub: str_field("sub"),
452 raw: map,
453 })
454}
455
456fn normalize_issuer(value: &str) -> String {
461 let no_scheme = value
462 .strip_prefix("https://")
463 .or_else(|| value.strip_prefix("http://"))
464 .unwrap_or(value);
465 no_scheme.trim_end_matches('/').to_string()
466}
467
468fn find_oidc_provider<'a>(
473 accounts: &'a fakecloud_core::multi_account::MultiAccountState<IamState>,
474 issuer: &str,
475) -> Option<(&'a str, &'a crate::state::OidcProvider)> {
476 let normalized = normalize_issuer(issuer);
477 for (acct_id, state) in accounts.iter() {
478 for provider in state.oidc_providers.values() {
479 if normalize_issuer(&provider.url) == normalized {
480 return Some((acct_id, provider));
481 }
482 }
483 }
484 None
485}
486
487fn find_saml_provider<'a>(
491 accounts: &'a fakecloud_core::multi_account::MultiAccountState<IamState>,
492 arn: &str,
493) -> Option<&'a crate::state::SamlProvider> {
494 for (_acct_id, state) in accounts.iter() {
495 if let Some(provider) = state.saml_providers.get(arn) {
496 return Some(provider);
497 }
498 }
499 None
500}
501
502fn expected_saml_audience(metadata: &str) -> Option<String> {
510 let needle = "entityID=";
511 let pos = metadata.find(needle)?;
512 let after = &metadata[pos + needle.len()..];
513 let quote = after.chars().next()?;
514 if quote != '"' && quote != '\'' {
515 return None;
516 }
517 let rest = &after[1..];
518 let end = rest.find(quote)?;
519 let value = rest[..end].trim();
520 if value.is_empty() {
521 None
522 } else {
523 Some(value.to_string())
524 }
525}
526
527fn federated_principal(provider_arn: &str, account_id: &str) -> Principal {
533 Principal {
534 arn: provider_arn.to_string(),
535 user_id: provider_arn.to_string(),
536 account_id: account_id.to_string(),
537 principal_type: PrincipalType::FederatedUser,
538 source_identity: None,
539 tags: None,
540 }
541}
542
543fn trust_policy_denied(action: &str, caller_arn: &str, role_arn: &str) -> AwsServiceError {
546 AwsServiceError::aws_error(
547 StatusCode::FORBIDDEN,
548 "AccessDenied",
549 format!(
550 "User: {} is not authorized to perform: {} on resource: {}",
551 caller_arn, action, role_arn
552 ),
553 )
554}
555
556fn extract_saml_session_name(saml_b64: &str) -> Option<String> {
558 use base64::Engine;
559 let decoded = base64::engine::general_purpose::STANDARD
560 .decode(saml_b64)
561 .ok()?;
562 let xml_str = String::from_utf8(decoded).ok()?;
563
564 let role_session_attr = "https://aws.amazon.com/SAML/Attributes/RoleSessionName";
566 let pos = xml_str.find(role_session_attr)?;
567
568 let after = &xml_str[pos..];
570 let av_start = after.find("AttributeValue")?;
571 let after_av = &after[av_start..];
572 let gt_pos = after_av.find('>')?;
574 let value_start = &after_av[gt_pos + 1..];
575 let lt_pos = value_start.find('<')?;
577 let value = value_start[..lt_pos].trim();
578
579 if value.is_empty() {
580 None
581 } else {
582 Some(value.to_string())
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn test_partition_for_region() {
592 assert_eq!(partition_for_region("us-east-1"), "aws");
593 assert_eq!(partition_for_region("eu-west-1"), "aws");
594 assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
595 assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
596 assert_eq!(partition_for_region("us-isob-east-1"), "aws-iso-b");
597 assert_eq!(partition_for_region("us-iso-east-1"), "aws-iso");
598 }
599
600 #[test]
601 fn test_extract_account_from_arn() {
602 assert_eq!(
603 extract_account_from_arn("arn:aws:iam::123456789012:role/test"),
604 Some("123456789012".to_string())
605 );
606 assert_eq!(
607 extract_account_from_arn("arn:aws:iam::111111111111:role/test"),
608 Some("111111111111".to_string())
609 );
610 assert_eq!(extract_account_from_arn("invalid"), None);
611 }
612
613 #[test]
614 fn test_extract_saml_session_name() {
615 use base64::Engine;
616 let xml = r#"<?xml version="1.0"?><samlp:Response><Assertion><AttributeStatement><Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><AttributeValue>testuser</AttributeValue></Attribute></AttributeStatement></Assertion></samlp:Response>"#;
617 let encoded = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes());
618 assert_eq!(
619 extract_saml_session_name(&encoded),
620 Some("testuser".to_string())
621 );
622 }
623
624 #[test]
625 fn test_extract_saml_session_name_with_namespace() {
626 use base64::Engine;
627 let xml = r#"<?xml version="1.0"?><samlp:Response><saml:Assertion><saml:AttributeStatement><saml:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><saml:AttributeValue>testuser</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion></samlp:Response>"#;
628 let encoded = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes());
629 assert_eq!(
630 extract_saml_session_name(&encoded),
631 Some("testuser".to_string())
632 );
633 }
634
635 #[test]
636 fn test_session_token_format() {
637 let token = xml_responses::generate_session_token();
638 assert_eq!(token.len(), 356);
639 assert!(token.starts_with("FQoGZXIvYXdzE"));
640 }
641
642 #[test]
643 fn test_access_key_id_format() {
644 let key = xml_responses::generate_access_key_id();
645 assert_eq!(key.len(), 20);
646 assert!(key.starts_with("FSIA"));
647 }
648
649 #[test]
650 fn test_secret_access_key_format() {
651 let key = xml_responses::generate_secret_access_key();
652 assert_eq!(key.len(), 40);
653 }
654
655 #[test]
656 fn test_role_id_format() {
657 let id = xml_responses::generate_role_id();
658 assert_eq!(id.len(), 21);
659 assert!(id.starts_with("AROA"));
660 }
661
662 #[test]
663 fn test_decode_authorization_message() {
664 use parking_lot::RwLock;
665 use std::collections::HashMap;
666 use std::sync::Arc;
667
668 let state: SharedIamState = Arc::new(RwLock::new(
669 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
670 ));
671 let service = StsService::new(state);
672
673 let token = crate::auth_message::encode_deny(
676 true,
677 Some("s3:GetObject"),
678 Some("arn:aws:iam::123456789012:user/alice"),
679 vec![serde_json::json!({"sourcePolicyId": "deny-bucket-foo"})],
680 None,
681 );
682 let mut params = HashMap::new();
683 params.insert("EncodedMessage".to_string(), token);
684
685 let req = make_test_request(params);
686 let resp = service.decode_authorization_message(&req).unwrap();
687 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
688 assert!(body.contains("DecodedMessage"));
689 assert!(body.contains("explicitDeny"));
690 assert!(body.contains("s3:GetObject"));
691 assert!(body.contains("deny-bucket-foo"));
692 }
693
694 #[test]
695 fn test_decode_authorization_message_rejects_invalid_token() {
696 use parking_lot::RwLock;
697 use std::collections::HashMap;
698 use std::sync::Arc;
699
700 let state: SharedIamState = Arc::new(RwLock::new(
701 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
702 ));
703 let service = StsService::new(state);
704
705 let mut params = HashMap::new();
706 params.insert("EncodedMessage".to_string(), "not-a-real-token".to_string());
707 let req = make_test_request(params);
708 let err = match service.decode_authorization_message(&req) {
709 Err(e) => e,
710 Ok(_) => panic!("expected InvalidAuthorizationMessageException"),
711 };
712 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
713 let msg = format!("{:?}", err);
714 assert!(msg.contains("InvalidAuthorizationMessageException"));
715 }
716
717 #[test]
718 fn test_decode_authorization_message_missing_param() {
719 use parking_lot::RwLock;
720 use std::collections::HashMap;
721 use std::sync::Arc;
722
723 let state: SharedIamState = Arc::new(RwLock::new(
724 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
725 ));
726 let service = StsService::new(state);
727
728 let req = make_test_request(HashMap::new());
729 let result = service.decode_authorization_message(&req);
730 assert!(result.is_err());
731 let err = result.err().unwrap();
732 let msg = format!("{:?}", err);
733 assert!(msg.contains("EncodedMessage"));
734 }
735
736 fn make_test_request(params: std::collections::HashMap<String, String>) -> AwsRequest {
737 AwsRequest {
738 service: "sts".into(),
739 action: "Test".into(),
740 region: "us-east-1".into(),
741 account_id: "123456789012".into(),
742 request_id: "test".into(),
743 headers: http::HeaderMap::new(),
744 query_params: params,
745 body: Default::default(),
746 body_stream: parking_lot::Mutex::new(None),
747 path_segments: vec![],
748 raw_path: "/".into(),
749 raw_query: String::new(),
750 method: http::Method::POST,
751 is_query_protocol: true,
752 access_key_id: None,
753 principal: None,
754 }
755 }
756
757 fn parse_expiration(s: &str) -> chrono::DateTime<Utc> {
758 chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ")
759 .expect("valid timestamp")
760 .and_utc()
761 }
762
763 #[test]
764 fn test_compute_expiration_with_duration() {
765 use std::collections::HashMap;
766
767 let mut params = HashMap::new();
768 params.insert("DurationSeconds".to_string(), "1800".to_string());
769 let req = make_test_request(params);
770
771 let now = Utc::now();
772 let exp_str = compute_expiration(&req, 3600).unwrap();
773 let exp_utc = parse_expiration(&exp_str);
774
775 let diff = (exp_utc - now).num_seconds();
777 assert!(
778 (1798..=1802).contains(&diff),
779 "expected ~1800s duration, got {diff}s"
780 );
781 }
782
783 #[test]
784 fn test_compute_expiration_default() {
785 use std::collections::HashMap;
786
787 let req = make_test_request(HashMap::new());
788
789 let now = Utc::now();
790 let exp_str = compute_expiration(&req, 43200).unwrap();
791 let exp_utc = parse_expiration(&exp_str);
792
793 let diff = (exp_utc - now).num_seconds();
795 assert!(
796 (43198..=43202).contains(&diff),
797 "expected ~43200s duration, got {diff}s"
798 );
799 }
800
801 #[test]
802 fn test_compute_expiration_uses_provided_not_default() {
803 use std::collections::HashMap;
804
805 let mut params = HashMap::new();
806 params.insert("DurationSeconds".to_string(), "900".to_string());
807 let req = make_test_request(params);
808
809 let before = Utc::now();
810 let exp_str = compute_expiration(&req, 43200).unwrap();
811 let exp_utc = parse_expiration(&exp_str);
812
813 let expected = before + chrono::Duration::seconds(900);
815 let diff = (exp_utc - expected).num_seconds().abs();
816 assert!(
817 diff <= 2,
818 "expected ~900s duration, got diff={diff}s from expected"
819 );
820 }
821
822 fn make_sts_service() -> (StsService, SharedIamState) {
823 use parking_lot::RwLock;
824 use std::sync::Arc;
825
826 let state: SharedIamState = Arc::new(RwLock::new(
827 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
828 ));
829 let sts = StsService::new(state.clone());
830 (sts, state)
831 }
832
833 fn sts_request(action: &str, params: Vec<(&str, &str)>) -> AwsRequest {
834 let mut qp = std::collections::HashMap::new();
835 qp.insert("Action".to_string(), action.to_string());
836 for (k, v) in params {
837 qp.insert(k.to_string(), v.to_string());
838 }
839 let mut req = make_test_request(qp);
840 req.action = action.to_string();
841 req
842 }
843
844 fn create_role_in_state(state: &SharedIamState, name: &str) -> String {
845 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
850 create_role_in_state_with_trust(state, name, trust)
851 }
852
853 fn create_role_in_state_with_trust(
854 state: &SharedIamState,
855 name: &str,
856 trust_policy: &str,
857 ) -> String {
858 let arn = fakecloud_aws::arn::Arn::global("iam", "123456789012", &format!("role/{name}"))
859 .to_string();
860 let mut accounts = state.write();
861 let s = accounts.get_or_create("123456789012");
862 s.roles.insert(
866 name.to_string(),
867 crate::state::IamRole {
868 role_name: name.to_string(),
869 role_id: format!("AROA{}", &uuid::Uuid::new_v4().to_string()[..17]),
870 arn: arn.clone(),
871 path: "/".to_string(),
872 assume_role_policy_document: trust_policy.to_string(),
873 created_at: Utc::now(),
874 description: None,
875 max_session_duration: 3600,
876 tags: Vec::new(),
877 permissions_boundary: None,
878 },
879 );
880 arn
881 }
882
883 #[tokio::test]
886 async fn get_caller_identity() {
887 let (svc, _) = make_sts_service();
888 let mut req = sts_request("GetCallerIdentity", vec![]);
889 req.headers.insert(
895 http::header::AUTHORIZATION,
896 http::HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test/test"),
897 );
898 let resp = svc.handle(req).await.unwrap();
899 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
900 assert!(body.contains("<Account>123456789012</Account>"));
901 assert!(body.contains("<Arn>"));
902 }
903
904 #[tokio::test]
905 async fn get_caller_identity_rejects_unauthenticated_request() {
906 let (svc, _) = make_sts_service();
911 let req = sts_request("GetCallerIdentity", vec![]);
912 let err = match svc.handle(req).await {
913 Err(e) => e,
914 Ok(_) => panic!("expected MissingAuthenticationTokenException"),
915 };
916 assert_eq!(err.status(), StatusCode::FORBIDDEN);
917 assert!(format!("{:?}", err).contains("MissingAuthenticationTokenException"));
918 }
919
920 #[tokio::test]
923 async fn assume_role_basic() {
924 let (svc, state) = make_sts_service();
925 let role_arn = create_role_in_state(&state, "test-role");
926
927 let req = sts_request(
928 "AssumeRole",
929 vec![("RoleArn", &role_arn), ("RoleSessionName", "test-session")],
930 );
931 let resp = svc.handle(req).await.unwrap();
932 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
933 assert!(body.contains("<AccessKeyId>"));
934 assert!(body.contains("<SecretAccessKey>"));
935 assert!(body.contains("<SessionToken>"));
936 }
937
938 #[tokio::test]
939 async fn assume_role_not_found() {
940 let (svc, _) = make_sts_service();
941 let req = sts_request(
942 "AssumeRole",
943 vec![
944 ("RoleArn", "arn:aws:iam::123456789012:role/nonexistent"),
945 ("RoleSessionName", "s"),
946 ],
947 );
948 assert!(svc.handle(req).await.is_err());
949 }
950
951 #[tokio::test]
952 async fn assume_role_missing_session_name() {
953 let (svc, _) = make_sts_service();
954 let req = sts_request(
955 "AssumeRole",
956 vec![("RoleArn", "arn:aws:iam::123456789012:role/r")],
957 );
958 assert!(svc.handle(req).await.is_err());
959 }
960
961 #[tokio::test]
964 async fn assume_role_with_web_identity() {
965 let (svc, state) = make_sts_service();
966 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
967 let role_arn = create_role_in_state_with_trust(&state, "web-role", trust);
968
969 let req = sts_request(
970 "AssumeRoleWithWebIdentity",
971 vec![
972 ("RoleArn", &role_arn),
973 ("RoleSessionName", "web-session"),
974 ("WebIdentityToken", "fake-jwt-token"),
975 ],
976 );
977 let resp = svc.handle(req).await.unwrap();
978 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
979 assert!(body.contains("<AccessKeyId>"));
980 }
981
982 #[tokio::test]
983 async fn assume_role_with_web_identity_rejects_expired_token() {
984 use base64::Engine;
988 let (svc, state) = make_sts_service();
989 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
990 let role_arn = create_role_in_state_with_trust(&state, "web-role", trust);
991
992 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
993 let payload_json = format!(
994 r#"{{"iss":"https://example.com","aud":"client","sub":"u","exp":{}}}"#,
995 chrono::Utc::now().timestamp() - 3600
996 );
997 let payload =
998 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
999 let token = format!("{header}.{payload}.sig");
1000
1001 let req = sts_request(
1002 "AssumeRoleWithWebIdentity",
1003 vec![
1004 ("RoleArn", &role_arn),
1005 ("RoleSessionName", "web-session"),
1006 ("WebIdentityToken", &token),
1007 ],
1008 );
1009 let err = match svc.handle(req).await {
1010 Err(e) => e,
1011 Ok(_) => panic!("expected ExpiredTokenException for an expired token"),
1012 };
1013 assert_eq!(err.code(), "ExpiredTokenException");
1014 }
1015
1016 #[tokio::test]
1017 async fn assume_role_with_web_identity_nonexistent_role_denied() {
1018 let (svc, _state) = make_sts_service();
1022 let req = sts_request(
1023 "AssumeRoleWithWebIdentity",
1024 vec![
1025 ("RoleArn", "arn:aws:iam::123456789012:role/does-not-exist"),
1026 ("RoleSessionName", "web-session"),
1027 ("WebIdentityToken", "fake-jwt-token"),
1028 ],
1029 );
1030 let err = match svc.handle(req).await {
1031 Err(e) => e,
1032 Ok(_) => panic!("expected AccessDenied for phantom role, got success"),
1033 };
1034 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1035 assert!(
1036 format!("{err:?}").contains("AccessDenied"),
1037 "expected AccessDenied, got {err:?}"
1038 );
1039 }
1040
1041 fn make_saml_assertion() -> String {
1044 use base64::Engine;
1045 let xml = r#"<saml:Assertion><saml:AttributeStatement><saml:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><saml:AttributeValue>saml-user</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion>"#;
1047 base64::engine::general_purpose::STANDARD.encode(xml.as_bytes())
1048 }
1049
1050 #[tokio::test]
1051 async fn assume_role_with_saml_existing_role_succeeds() {
1052 let (svc, state) = make_sts_service();
1053 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::123456789012:saml-provider/idp"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
1054 let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
1055 let assertion = make_saml_assertion();
1056 let req = sts_request(
1057 "AssumeRoleWithSAML",
1058 vec![
1059 ("RoleArn", &role_arn),
1060 (
1061 "PrincipalArn",
1062 "arn:aws:iam::123456789012:saml-provider/idp",
1063 ),
1064 ("SAMLAssertion", &assertion),
1065 ],
1066 );
1067 let resp = svc.handle(req).await.expect("existing role should succeed");
1068 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1069 assert!(body.contains("<AccessKeyId>"));
1070 }
1071
1072 #[tokio::test]
1073 async fn assume_role_with_saml_nonexistent_role_denied() {
1074 let (svc, _state) = make_sts_service();
1076 let assertion = make_saml_assertion();
1077 let req = sts_request(
1078 "AssumeRoleWithSAML",
1079 vec![
1080 ("RoleArn", "arn:aws:iam::123456789012:role/does-not-exist"),
1081 (
1082 "PrincipalArn",
1083 "arn:aws:iam::123456789012:saml-provider/idp",
1084 ),
1085 ("SAMLAssertion", &assertion),
1086 ],
1087 );
1088 let err = match svc.handle(req).await {
1089 Err(e) => e,
1090 Ok(_) => panic!("expected AccessDenied for phantom role, got success"),
1091 };
1092 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1093 assert!(
1094 format!("{err:?}").contains("AccessDenied"),
1095 "expected AccessDenied, got {err:?}"
1096 );
1097 }
1098
1099 #[tokio::test]
1102 async fn get_session_token() {
1103 let (svc, _) = make_sts_service();
1104 let req = sts_request("GetSessionToken", vec![]);
1105 let resp = svc.handle(req).await.unwrap();
1106 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1107 assert!(body.contains("<AccessKeyId>"));
1108 assert!(body.contains("<SessionToken>"));
1109 }
1110
1111 #[tokio::test]
1112 async fn get_session_token_with_duration() {
1113 let (svc, _) = make_sts_service();
1114 let req = sts_request("GetSessionToken", vec![("DurationSeconds", "1800")]);
1115 let resp = svc.handle(req).await.unwrap();
1116 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1117 assert!(body.contains("<Expiration>"));
1118 }
1119
1120 #[tokio::test]
1123 async fn get_federation_token() {
1124 let (svc, _) = make_sts_service();
1125 let req = sts_request("GetFederationToken", vec![("Name", "feduser")]);
1126 let resp = svc.handle(req).await.unwrap();
1127 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1128 assert!(body.contains("<AccessKeyId>"));
1129 assert!(body.contains("<FederatedUserId>"));
1130 }
1131
1132 #[tokio::test]
1135 async fn get_access_key_info() {
1136 let (svc, _) = make_sts_service();
1137 let req = sts_request(
1138 "GetAccessKeyInfo",
1139 vec![("AccessKeyId", "AKIAIOSFODNN7EXAMPLE")],
1140 );
1141 let resp = svc.handle(req).await.unwrap();
1142 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1143 assert!(body.contains("<Account>"));
1144 }
1145
1146 #[tokio::test]
1149 async fn assume_role_rejects_when_external_id_missing() {
1150 let (svc, state) = make_sts_service();
1153 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1154 let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1155 let req = sts_request(
1156 "AssumeRole",
1157 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1158 );
1159 let err = match svc.handle(req).await {
1160 Err(e) => e,
1161 Ok(_) => panic!("expected AccessDenied when ExternalId missing"),
1162 };
1163 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1164 }
1165
1166 #[tokio::test]
1167 async fn assume_role_rejects_when_external_id_mismatches() {
1168 let (svc, state) = make_sts_service();
1169 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1170 let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1171 let req = sts_request(
1172 "AssumeRole",
1173 vec![
1174 ("RoleArn", &role_arn),
1175 ("RoleSessionName", "sess"),
1176 ("ExternalId", "wrongguess"),
1177 ],
1178 );
1179 let err = match svc.handle(req).await {
1180 Err(e) => e,
1181 Ok(_) => panic!("expected AccessDenied when ExternalId mismatches"),
1182 };
1183 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1184 }
1185
1186 #[tokio::test]
1187 async fn assume_role_succeeds_when_external_id_matches() {
1188 let (svc, state) = make_sts_service();
1189 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1190 let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1191 let req = sts_request(
1192 "AssumeRole",
1193 vec![
1194 ("RoleArn", &role_arn),
1195 ("RoleSessionName", "sess"),
1196 ("ExternalId", "secret-handshake"),
1197 ],
1198 );
1199 let resp = svc.handle(req).await.unwrap();
1200 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1201 assert!(body.contains("<AccessKeyId>"));
1202 }
1203
1204 #[tokio::test]
1205 async fn assume_role_proceeds_when_no_external_id_required() {
1206 let (svc, state) = make_sts_service();
1209 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1210 let role_arn = create_role_in_state_with_trust(&state, "open-role", trust);
1211 let req = sts_request(
1212 "AssumeRole",
1213 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1214 );
1215 svc.handle(req).await.unwrap();
1216 }
1217
1218 #[tokio::test]
1221 async fn assume_role_rejects_when_trust_policy_has_no_statements() {
1222 let (svc, state) = make_sts_service();
1223 let role_arn = create_role_in_state_with_trust(&state, "no-trust", r#"{"Statement":[]}"#);
1224 let req = sts_request(
1225 "AssumeRole",
1226 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1227 );
1228 let err = match svc.handle(req).await {
1229 Err(e) => e,
1230 Ok(_) => panic!("expected AccessDenied"),
1231 };
1232 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1233 }
1234
1235 #[tokio::test]
1236 async fn assume_role_rejects_when_trust_policy_excludes_caller() {
1237 let (svc, state) = make_sts_service();
1238 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
1241 let role_arn = create_role_in_state_with_trust(&state, "ec2-only", trust);
1242 let req = sts_request(
1243 "AssumeRole",
1244 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1245 );
1246 let err = match svc.handle(req).await {
1247 Err(e) => e,
1248 Ok(_) => panic!("expected AccessDenied"),
1249 };
1250 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1251 }
1252
1253 #[tokio::test]
1254 async fn assume_role_rejects_when_trust_policy_explicitly_denies() {
1255 let (svc, state) = make_sts_service();
1256 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Effect":"Deny","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1257 let role_arn = create_role_in_state_with_trust(&state, "deny-wins", trust);
1258 let req = sts_request(
1259 "AssumeRole",
1260 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1261 );
1262 let err = match svc.handle(req).await {
1263 Err(e) => e,
1264 Ok(_) => panic!("expected AccessDenied"),
1265 };
1266 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1267 }
1268
1269 #[tokio::test]
1270 async fn assume_role_allowed_by_trust_policy_with_principal_match() {
1271 let (svc, state) = make_sts_service();
1275 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"123456789012"},"Action":"sts:AssumeRole"}]}"#;
1276 let role_arn = create_role_in_state_with_trust(&state, "named", trust);
1277 let req = sts_request(
1278 "AssumeRole",
1279 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1280 );
1281 let resp = svc.handle(req).await.unwrap();
1282 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1283 assert!(body.contains("<AccessKeyId>"), "{body}");
1284 }
1285
1286 #[tokio::test]
1287 async fn assume_role_blocked_when_principal_not_in_trust_policy() {
1288 let (svc, state) = make_sts_service();
1291 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::999999999999:root"},"Action":"sts:AssumeRole"}]}"#;
1292 let role_arn = create_role_in_state_with_trust(&state, "other-account", trust);
1293 let req = sts_request(
1294 "AssumeRole",
1295 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1296 );
1297 let err = match svc.handle(req).await {
1298 Err(e) => e,
1299 Ok(_) => panic!("expected AccessDenied when caller account not in trust policy"),
1300 };
1301 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1302 }
1303
1304 #[tokio::test]
1307 async fn assume_role_blocked_when_external_id_required_but_missing() {
1308 let (svc, state) = make_sts_service();
1309 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"hello"}}}]}"#;
1310 let role_arn = create_role_in_state_with_trust(&state, "ext-required", trust);
1311 let req = sts_request(
1312 "AssumeRole",
1313 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1314 );
1315 let err = match svc.handle(req).await {
1316 Err(e) => e,
1317 Ok(_) => panic!("expected AccessDenied when ExternalId required but missing"),
1318 };
1319 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1320 }
1321
1322 #[tokio::test]
1323 async fn assume_role_succeeds_with_correct_external_id() {
1324 let (svc, state) = make_sts_service();
1325 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"hello"}}}]}"#;
1326 let role_arn = create_role_in_state_with_trust(&state, "ext-ok", trust);
1327 let req = sts_request(
1328 "AssumeRole",
1329 vec![
1330 ("RoleArn", &role_arn),
1331 ("RoleSessionName", "sess"),
1332 ("ExternalId", "hello"),
1333 ],
1334 );
1335 svc.handle(req).await.unwrap();
1336 }
1337
1338 #[tokio::test]
1341 async fn assume_role_blocked_when_mfa_required_but_not_present() {
1342 let (svc, state) = make_sts_service();
1346 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}"#;
1347 let role_arn = create_role_in_state_with_trust(&state, "mfa-required", trust);
1348 let req = sts_request(
1349 "AssumeRole",
1350 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1351 );
1352 let err = match svc.handle(req).await {
1353 Err(e) => e,
1354 Ok(_) => panic!("expected AccessDenied when MFA required but not supplied"),
1355 };
1356 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1357 }
1358
1359 #[tokio::test]
1360 async fn assume_role_succeeds_with_mfa_supplied() {
1361 let (svc, state) = make_sts_service();
1366 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}"#;
1367 let role_arn = create_role_in_state_with_trust(&state, "mfa-ok", trust);
1368 let req = sts_request(
1369 "AssumeRole",
1370 vec![
1371 ("RoleArn", &role_arn),
1372 ("RoleSessionName", "sess"),
1373 ("SerialNumber", "arn:aws:iam::123456789012:mfa/alice"),
1374 ("TokenCode", "123456"),
1375 ],
1376 );
1377 let resp = svc.handle(req).await.unwrap();
1378 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1379 assert!(body.contains("<AccessKeyId>"), "{body}");
1380 let states = state.read();
1383 let s = states.get("123456789012").unwrap();
1384 let any_mfa = s.sts_temp_credentials.values().any(|c| c.mfa_present);
1385 assert!(
1386 any_mfa,
1387 "expected at least one minted credential with mfa_present=true"
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn assume_role_with_mfa_resolved_credential_drives_iam_evaluator() {
1393 use crate::credential_resolver::IamCredentialResolver;
1401 use crate::evaluator::{
1402 evaluate as eval_policies, EvalRequest, PolicyDocument, RequestContext,
1403 };
1404 use fakecloud_core::auth::{ConditionContext, CredentialResolver};
1405
1406 let (svc, state) = make_sts_service();
1407 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1408 let role_arn = create_role_in_state_with_trust(&state, "mfa-e2e", trust);
1409 let req = sts_request(
1410 "AssumeRole",
1411 vec![
1412 ("RoleArn", &role_arn),
1413 ("RoleSessionName", "ops"),
1414 ("SerialNumber", "arn:aws:iam::123456789012:mfa/alice"),
1415 ("TokenCode", "654321"),
1416 ],
1417 );
1418 let resp = svc.handle(req).await.unwrap();
1419 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1420 let access_key_id = body
1422 .split("<AccessKeyId>")
1423 .nth(1)
1424 .and_then(|s| s.split("</AccessKeyId>").next())
1425 .expect("response should contain AccessKeyId")
1426 .to_string();
1427 let resolver = IamCredentialResolver::new(state.clone());
1428 let resolved = resolver
1429 .resolve(&access_key_id)
1430 .expect("issued credential must resolve through the resolver");
1431 assert!(
1432 resolved.mfa_present,
1433 "F3: MFA flag must survive the resolver hop"
1434 );
1435 assert!(
1436 resolved.token_issued_at.is_some(),
1437 "F3: token_issued_at must be populated for STS sessions"
1438 );
1439
1440 let mut ctx: RequestContext = ConditionContext {
1444 aws_principal_arn: Some(resolved.principal.arn.clone()),
1445 aws_principal_account: Some(resolved.principal.account_id.clone()),
1446 aws_userid: Some(resolved.principal.user_id.clone()),
1447 aws_mfa_present: Some(resolved.mfa_present),
1448 aws_token_issue_time: resolved.token_issued_at,
1449 aws_federated_provider: resolved.federated_provider.clone(),
1450 ..Default::default()
1451 };
1452 if resolved.mfa_present {
1453 if let Some(issued) = resolved.token_issued_at {
1454 ctx.aws_mfa_age_seconds = Some(
1455 Utc::now()
1456 .signed_duration_since(issued)
1457 .num_seconds()
1458 .max(0),
1459 );
1460 }
1461 }
1462
1463 let policy = PolicyDocument::parse(
1464 r#"{"Version":"2012-10-17","Statement":[{
1465 "Effect":"Allow",
1466 "Action":"s3:GetObject",
1467 "Resource":"*",
1468 "Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}
1469 }]}"#,
1470 );
1471 let eval = EvalRequest {
1472 principal: &resolved.principal,
1473 action: "s3:GetObject".to_string(),
1474 resource: "arn:aws:s3:::secrets/k".to_string(),
1475 context: ctx,
1476 };
1477 let decision = eval_policies(&[policy], &eval);
1478 assert_eq!(
1479 decision,
1480 crate::evaluator::Decision::Allow,
1481 "F3: MFA-gated allow must fire when session was minted with MFA"
1482 );
1483
1484 let req_no_mfa = sts_request(
1487 "AssumeRole",
1488 vec![("RoleArn", &role_arn), ("RoleSessionName", "no-mfa")],
1489 );
1490 let resp_no_mfa = svc.handle(req_no_mfa).await.unwrap();
1491 let body_no_mfa = std::str::from_utf8(resp_no_mfa.body.expect_bytes()).unwrap();
1492 let akid_no_mfa = body_no_mfa
1493 .split("<AccessKeyId>")
1494 .nth(1)
1495 .and_then(|s| s.split("</AccessKeyId>").next())
1496 .unwrap()
1497 .to_string();
1498 let resolved_no_mfa = resolver.resolve(&akid_no_mfa).unwrap();
1499 assert!(!resolved_no_mfa.mfa_present);
1500 let policy2 = PolicyDocument::parse(
1501 r#"{"Version":"2012-10-17","Statement":[{
1502 "Effect":"Allow",
1503 "Action":"s3:GetObject",
1504 "Resource":"*",
1505 "Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}
1506 }]}"#,
1507 );
1508 let ctx2 = ConditionContext {
1509 aws_principal_arn: Some(resolved_no_mfa.principal.arn.clone()),
1510 aws_userid: Some(resolved_no_mfa.principal.user_id.clone()),
1511 aws_mfa_present: Some(resolved_no_mfa.mfa_present),
1512 aws_token_issue_time: resolved_no_mfa.token_issued_at,
1513 ..Default::default()
1514 };
1515 let eval2 = EvalRequest {
1516 principal: &resolved_no_mfa.principal,
1517 action: "s3:GetObject".to_string(),
1518 resource: "arn:aws:s3:::secrets/k".to_string(),
1519 context: ctx2,
1520 };
1521 assert_eq!(
1522 eval_policies(&[policy2], &eval2),
1523 crate::evaluator::Decision::ImplicitDeny,
1524 "F3: MFA-gated allow must NOT fire when session was minted without MFA"
1525 );
1526 }
1527
1528 #[tokio::test]
1529 async fn assume_role_with_saml_populates_federated_provider() {
1530 use crate::credential_resolver::IamCredentialResolver;
1533 use base64::Engine;
1534 use fakecloud_core::auth::CredentialResolver;
1535 let (svc, state) = make_sts_service();
1536 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
1537 let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
1538 let saml_xml = r#"<?xml version="1.0"?><samlp:Response><Assertion><AttributeStatement><Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><AttributeValue>jane</AttributeValue></Attribute></AttributeStatement></Assertion></samlp:Response>"#;
1539 let saml_b64 = base64::engine::general_purpose::STANDARD.encode(saml_xml);
1540 let provider_arn = "arn:aws:iam::123456789012:saml-provider/idp";
1541 let req = sts_request(
1542 "AssumeRoleWithSAML",
1543 vec![
1544 ("RoleArn", &role_arn),
1545 ("PrincipalArn", provider_arn),
1546 ("SAMLAssertion", &saml_b64),
1547 ],
1548 );
1549 let resp = svc.handle(req).await.unwrap();
1550 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1551 let access_key_id = body
1552 .split("<AccessKeyId>")
1553 .nth(1)
1554 .and_then(|s| s.split("</AccessKeyId>").next())
1555 .unwrap()
1556 .to_string();
1557 let resolver = IamCredentialResolver::new(state.clone());
1558 let resolved = resolver.resolve(&access_key_id).unwrap();
1559 assert_eq!(
1560 resolved.federated_provider.as_deref(),
1561 Some(provider_arn),
1562 "AssumeRoleWithSAML must populate aws:FederatedProvider with the SAML provider ARN"
1563 );
1564 }
1565
1566 #[tokio::test]
1567 async fn assume_role_with_web_identity_populates_federated_provider() {
1568 use crate::credential_resolver::IamCredentialResolver;
1574 use fakecloud_core::auth::CredentialResolver;
1575 let (svc, state) = make_sts_service();
1576 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
1577 let role_arn = create_role_in_state_with_trust(&state, "oidc-role", trust);
1578 let req = sts_request(
1579 "AssumeRoleWithWebIdentity",
1580 vec![
1581 ("RoleArn", &role_arn),
1582 ("RoleSessionName", "oidc-session"),
1583 ("WebIdentityToken", "fake-jwt-blob"),
1584 ("ProviderId", "accounts.google.com"),
1585 ],
1586 );
1587 let resp = svc.handle(req).await.unwrap();
1588 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1589 let access_key_id = body
1590 .split("<AccessKeyId>")
1591 .nth(1)
1592 .and_then(|s| s.split("</AccessKeyId>").next())
1593 .unwrap()
1594 .to_string();
1595 let resolver = IamCredentialResolver::new(state.clone());
1596 let resolved = resolver.resolve(&access_key_id).unwrap();
1597 assert_eq!(
1598 resolved.federated_provider.as_deref(),
1599 Some("accounts.google.com"),
1600 "AssumeRoleWithWebIdentity must carry ProviderId as aws:FederatedProvider"
1601 );
1602 }
1603
1604 #[tokio::test]
1605 async fn assume_role_userid_format_matches_aws() {
1606 use crate::credential_resolver::IamCredentialResolver;
1610 use fakecloud_core::auth::CredentialResolver;
1611 let (svc, state) = make_sts_service();
1612 let role_arn = create_role_in_state(&state, "userid-role");
1613 let req = sts_request(
1614 "AssumeRole",
1615 vec![("RoleArn", &role_arn), ("RoleSessionName", "carol")],
1616 );
1617 let resp = svc.handle(req).await.unwrap();
1618 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1619 let access_key_id = body
1620 .split("<AccessKeyId>")
1621 .nth(1)
1622 .and_then(|s| s.split("</AccessKeyId>").next())
1623 .unwrap()
1624 .to_string();
1625 let resolver = IamCredentialResolver::new(state);
1626 let resolved = resolver.resolve(&access_key_id).unwrap();
1627 let uid = &resolved.principal.user_id;
1628 assert!(
1629 uid.contains(':'),
1630 "assumed-role userid must be `<role-id>:<RoleSessionName>`, got `{uid}`"
1631 );
1632 assert!(
1633 uid.ends_with(":carol"),
1634 "assumed-role userid must end with the RoleSessionName, got `{uid}`"
1635 );
1636 }
1637
1638 #[tokio::test]
1641 async fn assume_service_linked_role_blocked_when_caller_not_matching_service() {
1642 let (svc, state) = make_sts_service();
1643 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
1644 let arn = fakecloud_aws::arn::Arn::global(
1645 "iam",
1646 "123456789012",
1647 "role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
1648 )
1649 .to_string();
1650 {
1651 let mut accounts = state.write();
1652 let s = accounts.get_or_create("123456789012");
1653 s.roles.insert(
1654 "AWSServiceRoleForECS".to_string(),
1655 crate::state::IamRole {
1656 role_name: "AWSServiceRoleForECS".to_string(),
1657 role_id: "AROASLRECS".to_string(),
1658 arn: arn.clone(),
1659 path: "/aws-service-role/ecs.amazonaws.com/".to_string(),
1660 assume_role_policy_document: trust.to_string(),
1661 created_at: Utc::now(),
1662 description: None,
1663 max_session_duration: 3600,
1664 tags: Vec::new(),
1665 permissions_boundary: None,
1666 },
1667 );
1668 }
1669 let req = sts_request(
1670 "AssumeRole",
1671 vec![("RoleArn", &arn), ("RoleSessionName", "sess")],
1672 );
1673 let err = match svc.handle(req).await {
1674 Err(e) => e,
1675 Ok(_) => {
1676 panic!("expected AccessDenied for service-linked role with non-service caller")
1677 }
1678 };
1679 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1680 }
1681
1682 #[tokio::test]
1683 async fn service_linked_role_rejects_non_service_caller() {
1684 let (svc, state) = make_sts_service();
1685 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1686 let arn = fakecloud_aws::arn::Arn::global(
1687 "iam",
1688 "123456789012",
1689 "role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForELB",
1690 )
1691 .to_string();
1692 {
1693 let mut accounts = state.write();
1694 let s = accounts.get_or_create("123456789012");
1695 s.roles.insert(
1696 "AWSServiceRoleForELB".to_string(),
1697 crate::state::IamRole {
1698 role_name: "AWSServiceRoleForELB".to_string(),
1699 role_id: "AROASLR".to_string(),
1700 arn: arn.clone(),
1701 path: "/aws-service-role/elasticloadbalancing.amazonaws.com/".to_string(),
1702 assume_role_policy_document: trust.to_string(),
1703 created_at: Utc::now(),
1704 description: None,
1705 max_session_duration: 3600,
1706 tags: Vec::new(),
1707 permissions_boundary: None,
1708 },
1709 );
1710 }
1711 let req = sts_request(
1712 "AssumeRole",
1713 vec![("RoleArn", &arn), ("RoleSessionName", "sess")],
1714 );
1715 let err = match svc.handle(req).await {
1716 Err(e) => e,
1717 Ok(_) => panic!("expected AccessDenied for non-service caller"),
1718 };
1719 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1720 }
1721
1722 #[tokio::test]
1725 async fn unsupported_sts_action() {
1726 let (svc, _) = make_sts_service();
1727 let req = sts_request("BogusAction", vec![]);
1728 assert!(svc.handle(req).await.is_err());
1729 }
1730
1731 #[tokio::test]
1732 async fn assume_role_missing_role_arn_errors() {
1733 let (svc, _) = make_sts_service();
1734 let req = sts_request("AssumeRole", vec![("RoleSessionName", "sess")]);
1735 assert!(svc.assume_role(&req).is_err());
1736 }
1737
1738 #[tokio::test]
1739 async fn assume_role_with_web_identity_missing_token_errors() {
1740 let (svc, _) = make_sts_service();
1741 let req = sts_request(
1742 "AssumeRoleWithWebIdentity",
1743 vec![
1744 ("RoleArn", "arn:aws:iam::123:role/r"),
1745 ("RoleSessionName", "s"),
1746 ],
1747 );
1748 assert!(svc.assume_role_with_web_identity(&req).is_err());
1749 }
1750
1751 #[tokio::test]
1752 async fn assume_role_with_saml_missing_assertion_errors() {
1753 let (svc, _) = make_sts_service();
1754 let req = sts_request(
1755 "AssumeRoleWithSAML",
1756 vec![
1757 ("RoleArn", "arn:aws:iam::123:role/r"),
1758 ("PrincipalArn", "arn:aws:iam::123:saml-provider/p"),
1759 ],
1760 );
1761 assert!(svc.assume_role_with_saml(&req).is_err());
1762 }
1763
1764 #[tokio::test]
1765 async fn get_session_token_returns_ok() {
1766 let (svc, _) = make_sts_service();
1767 let req = sts_request("GetSessionToken", vec![]);
1768 let resp = svc.get_session_token(&req).unwrap();
1769 assert_eq!(resp.status, http::StatusCode::OK);
1770 }
1771
1772 #[tokio::test]
1773 async fn get_federation_token_returns_ok() {
1774 let (svc, _) = make_sts_service();
1775 let req = sts_request("GetFederationToken", vec![("Name", "test-user")]);
1776 let resp = svc.get_federation_token(&req).unwrap();
1777 assert_eq!(resp.status, http::StatusCode::OK);
1778 }
1779
1780 #[tokio::test]
1781 async fn get_federation_token_missing_name_errors() {
1782 let (svc, _) = make_sts_service();
1783 let req = sts_request("GetFederationToken", vec![]);
1784 assert!(svc.get_federation_token(&req).is_err());
1785 }
1786
1787 #[tokio::test]
1790 async fn assume_root_with_account_id_succeeds() {
1791 let (svc, state) = make_sts_service();
1792 state
1794 .write()
1795 .get_or_create("123456789012")
1796 .organizations_root_sessions = true;
1797 let req = sts_request(
1798 "AssumeRoot",
1799 vec![
1800 ("TargetPrincipal", "111122223333"),
1801 (
1802 "TaskPolicyArn.arn",
1803 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1804 ),
1805 ],
1806 );
1807 let resp = svc.assume_root(&req).unwrap();
1808 assert_eq!(resp.status, http::StatusCode::OK);
1809 let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1810 assert!(body.contains("AccessKeyId"), "{body}");
1811 }
1812
1813 #[tokio::test]
1814 async fn assume_root_with_arn_succeeds() {
1815 let (svc, state) = make_sts_service();
1816 state
1818 .write()
1819 .get_or_create("123456789012")
1820 .organizations_root_sessions = true;
1821 let req = sts_request(
1822 "AssumeRoot",
1823 vec![
1824 ("TargetPrincipal", "arn:aws:iam::444455556666:root"),
1825 (
1826 "TaskPolicyArn.arn",
1827 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1828 ),
1829 ("DurationSeconds", "600"),
1830 ("SourceIdentity", "alice"),
1831 ],
1832 );
1833 let resp = svc.assume_root(&req).unwrap();
1834 let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1835 assert!(
1836 body.contains("<SourceIdentity>alice</SourceIdentity>"),
1837 "{body}"
1838 );
1839 }
1840
1841 #[tokio::test]
1842 async fn assume_root_denied_without_root_sessions() {
1843 let (svc, _) = make_sts_service();
1847 let req = sts_request(
1848 "AssumeRoot",
1849 vec![
1850 ("TargetPrincipal", "arn:aws:iam::444455556666:root"),
1851 (
1852 "TaskPolicyArn.arn",
1853 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1854 ),
1855 ],
1856 );
1857 let err = svc
1858 .assume_root(&req)
1859 .err()
1860 .expect("AssumeRoot must be denied without RootSessions");
1861 assert_eq!(err.code(), "AccessDeniedException");
1862 }
1863
1864 #[tokio::test]
1865 async fn assume_root_same_account_succeeds_without_root_sessions() {
1866 let (svc, _) = make_sts_service();
1870 let req = sts_request(
1871 "AssumeRoot",
1872 vec![
1873 ("TargetPrincipal", "123456789012"),
1874 (
1875 "TaskPolicyArn.arn",
1876 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1877 ),
1878 ],
1879 );
1880 let resp = svc.assume_root(&req).unwrap();
1881 assert_eq!(resp.status, http::StatusCode::OK);
1882 let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1883 assert!(body.contains("AccessKeyId"), "{body}");
1884 }
1885
1886 #[tokio::test]
1887 async fn assume_root_missing_task_policy_rejected() {
1888 let (svc, _) = make_sts_service();
1894 let req = sts_request("AssumeRoot", vec![("TargetPrincipal", "111122223333")]);
1895 let err = svc
1896 .assume_root(&req)
1897 .err()
1898 .expect("missing TaskPolicyArn must fail");
1899 assert_eq!(err.code(), "ExpiredTokenException");
1900 }
1901
1902 #[tokio::test]
1903 async fn assume_root_target_principal_below_min_length_rejected() {
1904 let (svc, _) = make_sts_service();
1907 let req = sts_request(
1908 "AssumeRoot",
1909 vec![
1910 ("TargetPrincipal", "not-an-id"),
1911 ("TaskPolicyArn.arn", "arn:aws:iam::aws:policy/X"),
1912 ],
1913 );
1914 let err = svc
1915 .assume_root(&req)
1916 .err()
1917 .expect("short TargetPrincipal must fail");
1918 assert_eq!(err.code(), "ExpiredTokenException");
1919 }
1920
1921 #[tokio::test]
1922 async fn assume_root_duration_above_max_rejected() {
1923 let (svc, _) = make_sts_service();
1927 let req = sts_request(
1928 "AssumeRoot",
1929 vec![
1930 ("TargetPrincipal", "111122223333"),
1931 ("TaskPolicyArn.arn", "arn:aws:iam::aws:policy/X"),
1932 ("DurationSeconds", "1800"),
1933 ],
1934 );
1935 let err = svc
1936 .assume_root(&req)
1937 .err()
1938 .expect("out-of-range DurationSeconds must fail");
1939 assert_eq!(err.code(), "ExpiredTokenException");
1940 }
1941}