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-gov-") {
227 "aws-us-gov"
228 } else if region.starts_with("us-iso-") {
229 "aws-iso"
230 } else if region.starts_with("us-isob-") {
231 "aws-iso-b"
232 } else if region.starts_with("us-isof-") {
233 "aws-iso-f"
234 } else if region.starts_with("eu-isoe-") {
235 "aws-iso-e"
236 } else {
237 "aws"
238 }
239}
240
241fn collect_session_policies(req: &AwsRequest, state: &IamState) -> Vec<String> {
249 let mut docs = Vec::new();
250 if let Some(inline) = req.query_params.get("Policy") {
251 docs.push(inline.clone());
252 }
253 for i in 1..=12 {
255 let key = format!("PolicyArns.member.{i}.arn");
256 let arn = match req.query_params.get(&key) {
257 Some(a) => a,
258 None => break,
259 };
260 match state
261 .policies
262 .get(arn.as_str())
263 .and_then(|p| {
264 p.versions
265 .iter()
266 .find(|v| v.is_default)
267 .or_else(|| p.versions.first())
268 })
269 .map(|v| v.document.clone())
270 {
271 Some(doc) => docs.push(doc),
272 None => {
273 tracing::debug!(
274 target: "fakecloud::iam::audit",
275 arn = %arn,
276 "PolicyArns entry does not resolve to a known managed policy; \
277 session will deny all actions covered by this entry"
278 );
279 docs.push(String::new());
280 }
281 }
282 }
283 docs
284}
285
286fn extract_access_key(req: &AwsRequest) -> Option<String> {
288 let auth = req.headers.get("authorization")?.to_str().ok()?;
289 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
290 Some(info.access_key)
291}
292
293fn sts_assume_root_error(msg: impl Into<String>) -> AwsServiceError {
299 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ExpiredTokenException", msg.into())
300}
301
302fn sts_web_identity_error(msg: impl Into<String>) -> AwsServiceError {
306 AwsServiceError::aws_error(
307 StatusCode::BAD_REQUEST,
308 "SessionDurationEscalationException",
309 msg.into(),
310 )
311}
312
313fn collect_audiences(req: &AwsRequest) -> Vec<String> {
317 let mut out = Vec::new();
318 for i in 1..=50 {
319 let key = format!("Audience.member.{i}");
320 match req.query_params.get(&key) {
321 Some(v) => out.push(v.clone()),
322 None => break,
323 }
324 }
325 out
326}
327
328fn extract_account_from_arn(arn: &str) -> Option<String> {
330 let parts: Vec<&str> = arn.split(':').collect();
331 if parts.len() >= 5 && !parts[4].is_empty() {
332 Some(parts[4].to_string())
333 } else {
334 None
335 }
336}
337
338#[derive(Debug, Clone, Default)]
343struct SamlClaims {
344 issuer: Option<String>,
345 audience: Option<String>,
346}
347
348fn extract_saml_claims(saml_b64: &str) -> SamlClaims {
352 use base64::Engine;
353 let mut claims = SamlClaims::default();
354 let decoded = match base64::engine::general_purpose::STANDARD.decode(saml_b64) {
355 Ok(b) => b,
356 Err(_) => return claims,
357 };
358 let xml_str = match String::from_utf8(decoded) {
359 Ok(s) => s,
360 Err(_) => return claims,
361 };
362 claims.issuer = extract_xml_text_after(&xml_str, "Issuer");
363 claims.audience = extract_xml_text_after(&xml_str, "Audience");
364 claims
365}
366
367fn extract_xml_text_after(xml: &str, local_name: &str) -> Option<String> {
372 let mut search_from = 0;
376 while let Some(idx) = xml[search_from..].find('<') {
377 let abs = search_from + idx;
378 let after_lt = &xml[abs + 1..];
379 let tag_start = after_lt
381 .split_once(':')
382 .map(|(_pfx, rest)| rest)
383 .unwrap_or(after_lt);
384 if let Some(after_name) = tag_start.strip_prefix(local_name) {
385 let valid_terminator = after_name
387 .chars()
388 .next()
389 .map(|c| c == '>' || c == ' ' || c == '/' || c == '\t' || c == '\n')
390 .unwrap_or(false);
391 if valid_terminator {
392 let gt_pos = after_lt.find('>')?;
393 let content_start = abs + 1 + gt_pos + 1;
394 let next_lt = xml[content_start..].find('<')?;
395 let value = xml[content_start..content_start + next_lt].trim();
396 if !value.is_empty() {
397 return Some(value.to_string());
398 }
399 }
400 }
401 search_from = abs + 1;
402 }
403 None
404}
405
406#[derive(Debug, Clone, Default)]
411struct JwtClaims {
412 iss: Option<String>,
413 aud: Vec<String>,
418 sub: Option<String>,
419 raw: serde_json::Map<String, serde_json::Value>,
420}
421
422fn decode_jwt(token: &str) -> Option<JwtClaims> {
427 use base64::Engine;
428 let segments: Vec<&str> = token.split('.').collect();
429 if segments.len() != 3 {
430 return None;
431 }
432 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
433 .decode(segments[1])
434 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(segments[1]))
435 .ok()?;
436 let json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
437 let map = json.as_object()?.clone();
438 let str_field = |k: &str| map.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
439 let aud = match map.get("aud") {
443 Some(serde_json::Value::String(s)) => vec![s.clone()],
444 Some(serde_json::Value::Array(arr)) => arr
445 .iter()
446 .filter_map(|v| v.as_str().map(|s| s.to_string()))
447 .collect(),
448 _ => Vec::new(),
449 };
450 Some(JwtClaims {
451 iss: str_field("iss"),
452 aud,
453 sub: str_field("sub"),
454 raw: map,
455 })
456}
457
458fn normalize_issuer(value: &str) -> String {
463 let no_scheme = value
464 .strip_prefix("https://")
465 .or_else(|| value.strip_prefix("http://"))
466 .unwrap_or(value);
467 no_scheme.trim_end_matches('/').to_string()
468}
469
470fn find_oidc_provider<'a>(
475 accounts: &'a fakecloud_core::multi_account::MultiAccountState<IamState>,
476 issuer: &str,
477) -> Option<(&'a str, &'a crate::state::OidcProvider)> {
478 let normalized = normalize_issuer(issuer);
479 for (acct_id, state) in accounts.iter() {
480 for provider in state.oidc_providers.values() {
481 if normalize_issuer(&provider.url) == normalized {
482 return Some((acct_id, provider));
483 }
484 }
485 }
486 None
487}
488
489fn find_saml_provider<'a>(
493 accounts: &'a fakecloud_core::multi_account::MultiAccountState<IamState>,
494 arn: &str,
495) -> Option<&'a crate::state::SamlProvider> {
496 for (_acct_id, state) in accounts.iter() {
497 if let Some(provider) = state.saml_providers.get(arn) {
498 return Some(provider);
499 }
500 }
501 None
502}
503
504fn expected_saml_audience(metadata: &str) -> Option<String> {
512 let needle = "entityID=";
513 let pos = metadata.find(needle)?;
514 let after = &metadata[pos + needle.len()..];
515 let quote = after.chars().next()?;
516 if quote != '"' && quote != '\'' {
517 return None;
518 }
519 let rest = &after[1..];
520 let end = rest.find(quote)?;
521 let value = rest[..end].trim();
522 if value.is_empty() {
523 None
524 } else {
525 Some(value.to_string())
526 }
527}
528
529fn federated_principal(provider_arn: &str, account_id: &str) -> Principal {
535 Principal {
536 arn: provider_arn.to_string(),
537 user_id: provider_arn.to_string(),
538 account_id: account_id.to_string(),
539 principal_type: PrincipalType::FederatedUser,
540 source_identity: None,
541 tags: None,
542 }
543}
544
545fn trust_policy_denied(action: &str, caller_arn: &str, role_arn: &str) -> AwsServiceError {
548 AwsServiceError::aws_error(
549 StatusCode::FORBIDDEN,
550 "AccessDenied",
551 format!(
552 "User: {} is not authorized to perform: {} on resource: {}",
553 caller_arn, action, role_arn
554 ),
555 )
556}
557
558fn extract_saml_session_name(saml_b64: &str) -> Option<String> {
560 use base64::Engine;
561 let decoded = base64::engine::general_purpose::STANDARD
562 .decode(saml_b64)
563 .ok()?;
564 let xml_str = String::from_utf8(decoded).ok()?;
565
566 let role_session_attr = "https://aws.amazon.com/SAML/Attributes/RoleSessionName";
568 let pos = xml_str.find(role_session_attr)?;
569
570 let after = &xml_str[pos..];
572 let av_start = after.find("AttributeValue")?;
573 let after_av = &after[av_start..];
574 let gt_pos = after_av.find('>')?;
576 let value_start = &after_av[gt_pos + 1..];
577 let lt_pos = value_start.find('<')?;
579 let value = value_start[..lt_pos].trim();
580
581 if value.is_empty() {
582 None
583 } else {
584 Some(value.to_string())
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn test_partition_for_region() {
594 assert_eq!(partition_for_region("us-east-1"), "aws");
595 assert_eq!(partition_for_region("eu-west-1"), "aws");
596 assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
597 assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
598 assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
599 assert_eq!(partition_for_region("us-gov-east-1"), "aws-us-gov");
600 assert_eq!(partition_for_region("us-isob-east-1"), "aws-iso-b");
601 assert_eq!(partition_for_region("us-iso-east-1"), "aws-iso");
602 }
603
604 #[test]
605 fn test_extract_account_from_arn() {
606 assert_eq!(
607 extract_account_from_arn("arn:aws:iam::123456789012:role/test"),
608 Some("123456789012".to_string())
609 );
610 assert_eq!(
611 extract_account_from_arn("arn:aws:iam::111111111111:role/test"),
612 Some("111111111111".to_string())
613 );
614 assert_eq!(extract_account_from_arn("invalid"), None);
615 }
616
617 #[test]
618 fn test_extract_saml_session_name() {
619 use base64::Engine;
620 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>"#;
621 let encoded = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes());
622 assert_eq!(
623 extract_saml_session_name(&encoded),
624 Some("testuser".to_string())
625 );
626 }
627
628 #[test]
629 fn test_extract_saml_session_name_with_namespace() {
630 use base64::Engine;
631 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>"#;
632 let encoded = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes());
633 assert_eq!(
634 extract_saml_session_name(&encoded),
635 Some("testuser".to_string())
636 );
637 }
638
639 #[test]
640 fn test_session_token_format() {
641 let token = xml_responses::generate_session_token();
642 assert_eq!(token.len(), 356);
643 assert!(token.starts_with("FQoGZXIvYXdzE"));
644 }
645
646 #[test]
647 fn test_access_key_id_format() {
648 let key = xml_responses::generate_access_key_id();
649 assert_eq!(key.len(), 20);
650 assert!(key.starts_with("FSIA"));
651 }
652
653 #[test]
654 fn test_secret_access_key_format() {
655 let key = xml_responses::generate_secret_access_key();
656 assert_eq!(key.len(), 40);
657 }
658
659 #[test]
660 fn test_role_id_format() {
661 let id = xml_responses::generate_role_id();
662 assert_eq!(id.len(), 21);
663 assert!(id.starts_with("AROA"));
664 }
665
666 #[test]
667 fn test_decode_authorization_message() {
668 use parking_lot::RwLock;
669 use std::collections::HashMap;
670 use std::sync::Arc;
671
672 let state: SharedIamState = Arc::new(RwLock::new(
673 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
674 ));
675 let service = StsService::new(state);
676
677 let token = crate::auth_message::encode_deny(
680 true,
681 Some("s3:GetObject"),
682 Some("arn:aws:iam::123456789012:user/alice"),
683 vec![serde_json::json!({"sourcePolicyId": "deny-bucket-foo"})],
684 None,
685 );
686 let mut params = HashMap::new();
687 params.insert("EncodedMessage".to_string(), token);
688
689 let req = make_test_request(params);
690 let resp = service.decode_authorization_message(&req).unwrap();
691 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
692 assert!(body.contains("DecodedMessage"));
693 assert!(body.contains("explicitDeny"));
694 assert!(body.contains("s3:GetObject"));
695 assert!(body.contains("deny-bucket-foo"));
696 }
697
698 #[test]
699 fn test_decode_authorization_message_rejects_invalid_token() {
700 use parking_lot::RwLock;
701 use std::collections::HashMap;
702 use std::sync::Arc;
703
704 let state: SharedIamState = Arc::new(RwLock::new(
705 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
706 ));
707 let service = StsService::new(state);
708
709 let mut params = HashMap::new();
710 params.insert("EncodedMessage".to_string(), "not-a-real-token".to_string());
711 let req = make_test_request(params);
712 let err = match service.decode_authorization_message(&req) {
713 Err(e) => e,
714 Ok(_) => panic!("expected InvalidAuthorizationMessageException"),
715 };
716 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
717 let msg = format!("{:?}", err);
718 assert!(msg.contains("InvalidAuthorizationMessageException"));
719 }
720
721 #[test]
722 fn test_decode_authorization_message_missing_param() {
723 use parking_lot::RwLock;
724 use std::collections::HashMap;
725 use std::sync::Arc;
726
727 let state: SharedIamState = Arc::new(RwLock::new(
728 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
729 ));
730 let service = StsService::new(state);
731
732 let req = make_test_request(HashMap::new());
733 let result = service.decode_authorization_message(&req);
734 assert!(result.is_err());
735 let err = result.err().unwrap();
736 let msg = format!("{:?}", err);
737 assert!(msg.contains("EncodedMessage"));
738 }
739
740 fn make_test_request(params: std::collections::HashMap<String, String>) -> AwsRequest {
741 AwsRequest {
742 service: "sts".into(),
743 action: "Test".into(),
744 region: "us-east-1".into(),
745 account_id: "123456789012".into(),
746 request_id: "test".into(),
747 headers: http::HeaderMap::new(),
748 query_params: params,
749 body: Default::default(),
750 body_stream: parking_lot::Mutex::new(None),
751 path_segments: vec![],
752 raw_path: "/".into(),
753 raw_query: String::new(),
754 method: http::Method::POST,
755 is_query_protocol: true,
756 access_key_id: None,
757 principal: None,
758 }
759 }
760
761 fn parse_expiration(s: &str) -> chrono::DateTime<Utc> {
762 chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ")
763 .expect("valid timestamp")
764 .and_utc()
765 }
766
767 #[test]
768 fn test_compute_expiration_with_duration() {
769 use std::collections::HashMap;
770
771 let mut params = HashMap::new();
772 params.insert("DurationSeconds".to_string(), "1800".to_string());
773 let req = make_test_request(params);
774
775 let now = Utc::now();
776 let exp_str = compute_expiration(&req, 3600).unwrap();
777 let exp_utc = parse_expiration(&exp_str);
778
779 let diff = (exp_utc - now).num_seconds();
781 assert!(
782 (1798..=1802).contains(&diff),
783 "expected ~1800s duration, got {diff}s"
784 );
785 }
786
787 #[test]
788 fn test_compute_expiration_default() {
789 use std::collections::HashMap;
790
791 let req = make_test_request(HashMap::new());
792
793 let now = Utc::now();
794 let exp_str = compute_expiration(&req, 43200).unwrap();
795 let exp_utc = parse_expiration(&exp_str);
796
797 let diff = (exp_utc - now).num_seconds();
799 assert!(
800 (43198..=43202).contains(&diff),
801 "expected ~43200s duration, got {diff}s"
802 );
803 }
804
805 #[test]
806 fn test_compute_expiration_uses_provided_not_default() {
807 use std::collections::HashMap;
808
809 let mut params = HashMap::new();
810 params.insert("DurationSeconds".to_string(), "900".to_string());
811 let req = make_test_request(params);
812
813 let before = Utc::now();
814 let exp_str = compute_expiration(&req, 43200).unwrap();
815 let exp_utc = parse_expiration(&exp_str);
816
817 let expected = before + chrono::Duration::seconds(900);
819 let diff = (exp_utc - expected).num_seconds().abs();
820 assert!(
821 diff <= 2,
822 "expected ~900s duration, got diff={diff}s from expected"
823 );
824 }
825
826 fn make_sts_service() -> (StsService, SharedIamState) {
827 use parking_lot::RwLock;
828 use std::sync::Arc;
829
830 let state: SharedIamState = Arc::new(RwLock::new(
831 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
832 ));
833 let sts = StsService::new(state.clone());
834 (sts, state)
835 }
836
837 fn sts_request(action: &str, params: Vec<(&str, &str)>) -> AwsRequest {
838 let mut qp = std::collections::HashMap::new();
839 qp.insert("Action".to_string(), action.to_string());
840 for (k, v) in params {
841 qp.insert(k.to_string(), v.to_string());
842 }
843 let mut req = make_test_request(qp);
844 req.action = action.to_string();
845 req
846 }
847
848 fn create_role_in_state(state: &SharedIamState, name: &str) -> String {
849 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
854 create_role_in_state_with_trust(state, name, trust)
855 }
856
857 fn create_role_in_state_with_trust(
858 state: &SharedIamState,
859 name: &str,
860 trust_policy: &str,
861 ) -> String {
862 let arn = fakecloud_aws::arn::Arn::global("iam", "123456789012", &format!("role/{name}"))
863 .to_string();
864 let mut accounts = state.write();
865 let s = accounts.get_or_create("123456789012");
866 s.roles.insert(
870 name.to_string(),
871 crate::state::IamRole {
872 role_name: name.to_string(),
873 role_id: format!("AROA{}", &uuid::Uuid::new_v4().to_string()[..17]),
874 arn: arn.clone(),
875 path: "/".to_string(),
876 assume_role_policy_document: trust_policy.to_string(),
877 created_at: Utc::now(),
878 description: None,
879 max_session_duration: 3600,
880 tags: Vec::new(),
881 permissions_boundary: None,
882 },
883 );
884 arn
885 }
886
887 #[tokio::test]
890 async fn get_caller_identity() {
891 let (svc, _) = make_sts_service();
892 let mut req = sts_request("GetCallerIdentity", vec![]);
893 req.headers.insert(
899 http::header::AUTHORIZATION,
900 http::HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test/test"),
901 );
902 let resp = svc.handle(req).await.unwrap();
903 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
904 assert!(body.contains("<Account>123456789012</Account>"));
905 assert!(body.contains("<Arn>"));
906 }
907
908 #[tokio::test]
909 async fn get_caller_identity_rejects_unauthenticated_request() {
910 let (svc, _) = make_sts_service();
915 let req = sts_request("GetCallerIdentity", vec![]);
916 let err = match svc.handle(req).await {
917 Err(e) => e,
918 Ok(_) => panic!("expected MissingAuthenticationTokenException"),
919 };
920 assert_eq!(err.status(), StatusCode::FORBIDDEN);
921 assert!(format!("{:?}", err).contains("MissingAuthenticationTokenException"));
922 }
923
924 #[tokio::test]
927 async fn assume_role_basic() {
928 let (svc, state) = make_sts_service();
929 let role_arn = create_role_in_state(&state, "test-role");
930
931 let req = sts_request(
932 "AssumeRole",
933 vec![("RoleArn", &role_arn), ("RoleSessionName", "test-session")],
934 );
935 let resp = svc.handle(req).await.unwrap();
936 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
937 assert!(body.contains("<AccessKeyId>"));
938 assert!(body.contains("<SecretAccessKey>"));
939 assert!(body.contains("<SessionToken>"));
940 }
941
942 #[tokio::test]
943 async fn assume_role_not_found() {
944 let (svc, _) = make_sts_service();
945 let req = sts_request(
946 "AssumeRole",
947 vec![
948 ("RoleArn", "arn:aws:iam::123456789012:role/nonexistent"),
949 ("RoleSessionName", "s"),
950 ],
951 );
952 assert!(svc.handle(req).await.is_err());
953 }
954
955 #[tokio::test]
956 async fn assume_role_missing_session_name() {
957 let (svc, _) = make_sts_service();
958 let req = sts_request(
959 "AssumeRole",
960 vec![("RoleArn", "arn:aws:iam::123456789012:role/r")],
961 );
962 assert!(svc.handle(req).await.is_err());
963 }
964
965 #[tokio::test]
968 async fn assume_role_with_web_identity() {
969 let (svc, state) = make_sts_service();
970 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
971 let role_arn = create_role_in_state_with_trust(&state, "web-role", trust);
972
973 let req = sts_request(
974 "AssumeRoleWithWebIdentity",
975 vec![
976 ("RoleArn", &role_arn),
977 ("RoleSessionName", "web-session"),
978 ("WebIdentityToken", "fake-jwt-token"),
979 ],
980 );
981 let resp = svc.handle(req).await.unwrap();
982 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
983 assert!(body.contains("<AccessKeyId>"));
984 }
985
986 #[tokio::test]
987 async fn assume_role_with_web_identity_rejects_expired_token() {
988 use base64::Engine;
992 let (svc, state) = make_sts_service();
993 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
994 let role_arn = create_role_in_state_with_trust(&state, "web-role", trust);
995
996 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
997 let payload_json = format!(
998 r#"{{"iss":"https://example.com","aud":"client","sub":"u","exp":{}}}"#,
999 chrono::Utc::now().timestamp() - 3600
1000 );
1001 let payload =
1002 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
1003 let token = format!("{header}.{payload}.sig");
1004
1005 let req = sts_request(
1006 "AssumeRoleWithWebIdentity",
1007 vec![
1008 ("RoleArn", &role_arn),
1009 ("RoleSessionName", "web-session"),
1010 ("WebIdentityToken", &token),
1011 ],
1012 );
1013 let err = match svc.handle(req).await {
1014 Err(e) => e,
1015 Ok(_) => panic!("expected ExpiredTokenException for an expired token"),
1016 };
1017 assert_eq!(err.code(), "ExpiredTokenException");
1018 }
1019
1020 #[tokio::test]
1021 async fn assume_role_with_web_identity_nonexistent_role_denied() {
1022 let (svc, _state) = make_sts_service();
1026 let req = sts_request(
1027 "AssumeRoleWithWebIdentity",
1028 vec![
1029 ("RoleArn", "arn:aws:iam::123456789012:role/does-not-exist"),
1030 ("RoleSessionName", "web-session"),
1031 ("WebIdentityToken", "fake-jwt-token"),
1032 ],
1033 );
1034 let err = match svc.handle(req).await {
1035 Err(e) => e,
1036 Ok(_) => panic!("expected AccessDenied for phantom role, got success"),
1037 };
1038 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1039 assert!(
1040 format!("{err:?}").contains("AccessDenied"),
1041 "expected AccessDenied, got {err:?}"
1042 );
1043 }
1044
1045 fn make_saml_assertion() -> String {
1048 use base64::Engine;
1049 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>"#;
1051 base64::engine::general_purpose::STANDARD.encode(xml.as_bytes())
1052 }
1053
1054 #[tokio::test]
1055 async fn assume_role_with_saml_existing_role_succeeds() {
1056 let (svc, state) = make_sts_service();
1057 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::123456789012:saml-provider/idp"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
1058 let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
1059 let assertion = make_saml_assertion();
1060 let req = sts_request(
1061 "AssumeRoleWithSAML",
1062 vec![
1063 ("RoleArn", &role_arn),
1064 (
1065 "PrincipalArn",
1066 "arn:aws:iam::123456789012:saml-provider/idp",
1067 ),
1068 ("SAMLAssertion", &assertion),
1069 ],
1070 );
1071 let resp = svc.handle(req).await.expect("existing role should succeed");
1072 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1073 assert!(body.contains("<AccessKeyId>"));
1074 }
1075
1076 #[tokio::test]
1077 async fn assume_role_with_saml_nonexistent_role_denied() {
1078 let (svc, _state) = make_sts_service();
1080 let assertion = make_saml_assertion();
1081 let req = sts_request(
1082 "AssumeRoleWithSAML",
1083 vec![
1084 ("RoleArn", "arn:aws:iam::123456789012:role/does-not-exist"),
1085 (
1086 "PrincipalArn",
1087 "arn:aws:iam::123456789012:saml-provider/idp",
1088 ),
1089 ("SAMLAssertion", &assertion),
1090 ],
1091 );
1092 let err = match svc.handle(req).await {
1093 Err(e) => e,
1094 Ok(_) => panic!("expected AccessDenied for phantom role, got success"),
1095 };
1096 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1097 assert!(
1098 format!("{err:?}").contains("AccessDenied"),
1099 "expected AccessDenied, got {err:?}"
1100 );
1101 }
1102
1103 #[tokio::test]
1106 async fn get_session_token() {
1107 let (svc, _) = make_sts_service();
1108 let req = sts_request("GetSessionToken", vec![]);
1109 let resp = svc.handle(req).await.unwrap();
1110 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1111 assert!(body.contains("<AccessKeyId>"));
1112 assert!(body.contains("<SessionToken>"));
1113 }
1114
1115 #[tokio::test]
1116 async fn get_session_token_with_duration() {
1117 let (svc, _) = make_sts_service();
1118 let req = sts_request("GetSessionToken", vec![("DurationSeconds", "1800")]);
1119 let resp = svc.handle(req).await.unwrap();
1120 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1121 assert!(body.contains("<Expiration>"));
1122 }
1123
1124 #[tokio::test]
1127 async fn get_federation_token() {
1128 let (svc, _) = make_sts_service();
1129 let req = sts_request("GetFederationToken", vec![("Name", "feduser")]);
1130 let resp = svc.handle(req).await.unwrap();
1131 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1132 assert!(body.contains("<AccessKeyId>"));
1133 assert!(body.contains("<FederatedUserId>"));
1134 }
1135
1136 #[tokio::test]
1139 async fn get_access_key_info() {
1140 let (svc, _) = make_sts_service();
1141 let req = sts_request(
1142 "GetAccessKeyInfo",
1143 vec![("AccessKeyId", "AKIAIOSFODNN7EXAMPLE")],
1144 );
1145 let resp = svc.handle(req).await.unwrap();
1146 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1147 assert!(body.contains("<Account>"));
1148 }
1149
1150 #[tokio::test]
1153 async fn assume_role_rejects_when_external_id_missing() {
1154 let (svc, state) = make_sts_service();
1157 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1158 let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1159 let req = sts_request(
1160 "AssumeRole",
1161 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1162 );
1163 let err = match svc.handle(req).await {
1164 Err(e) => e,
1165 Ok(_) => panic!("expected AccessDenied when ExternalId missing"),
1166 };
1167 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1168 }
1169
1170 #[tokio::test]
1171 async fn assume_role_rejects_when_external_id_mismatches() {
1172 let (svc, state) = make_sts_service();
1173 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1174 let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1175 let req = sts_request(
1176 "AssumeRole",
1177 vec![
1178 ("RoleArn", &role_arn),
1179 ("RoleSessionName", "sess"),
1180 ("ExternalId", "wrongguess"),
1181 ],
1182 );
1183 let err = match svc.handle(req).await {
1184 Err(e) => e,
1185 Ok(_) => panic!("expected AccessDenied when ExternalId mismatches"),
1186 };
1187 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1188 }
1189
1190 #[tokio::test]
1191 async fn assume_role_succeeds_when_external_id_matches() {
1192 let (svc, state) = make_sts_service();
1193 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1194 let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1195 let req = sts_request(
1196 "AssumeRole",
1197 vec![
1198 ("RoleArn", &role_arn),
1199 ("RoleSessionName", "sess"),
1200 ("ExternalId", "secret-handshake"),
1201 ],
1202 );
1203 let resp = svc.handle(req).await.unwrap();
1204 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1205 assert!(body.contains("<AccessKeyId>"));
1206 }
1207
1208 #[tokio::test]
1209 async fn assume_role_proceeds_when_no_external_id_required() {
1210 let (svc, state) = make_sts_service();
1213 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1214 let role_arn = create_role_in_state_with_trust(&state, "open-role", trust);
1215 let req = sts_request(
1216 "AssumeRole",
1217 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1218 );
1219 svc.handle(req).await.unwrap();
1220 }
1221
1222 #[tokio::test]
1225 async fn assume_role_rejects_when_trust_policy_has_no_statements() {
1226 let (svc, state) = make_sts_service();
1227 let role_arn = create_role_in_state_with_trust(&state, "no-trust", r#"{"Statement":[]}"#);
1228 let req = sts_request(
1229 "AssumeRole",
1230 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1231 );
1232 let err = match svc.handle(req).await {
1233 Err(e) => e,
1234 Ok(_) => panic!("expected AccessDenied"),
1235 };
1236 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1237 }
1238
1239 #[tokio::test]
1240 async fn assume_role_rejects_when_trust_policy_excludes_caller() {
1241 let (svc, state) = make_sts_service();
1242 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
1245 let role_arn = create_role_in_state_with_trust(&state, "ec2-only", trust);
1246 let req = sts_request(
1247 "AssumeRole",
1248 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1249 );
1250 let err = match svc.handle(req).await {
1251 Err(e) => e,
1252 Ok(_) => panic!("expected AccessDenied"),
1253 };
1254 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1255 }
1256
1257 #[tokio::test]
1258 async fn assume_role_rejects_when_trust_policy_explicitly_denies() {
1259 let (svc, state) = make_sts_service();
1260 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Effect":"Deny","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1261 let role_arn = create_role_in_state_with_trust(&state, "deny-wins", trust);
1262 let req = sts_request(
1263 "AssumeRole",
1264 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1265 );
1266 let err = match svc.handle(req).await {
1267 Err(e) => e,
1268 Ok(_) => panic!("expected AccessDenied"),
1269 };
1270 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1271 }
1272
1273 #[tokio::test]
1274 async fn assume_role_allowed_by_trust_policy_with_principal_match() {
1275 let (svc, state) = make_sts_service();
1279 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"123456789012"},"Action":"sts:AssumeRole"}]}"#;
1280 let role_arn = create_role_in_state_with_trust(&state, "named", trust);
1281 let req = sts_request(
1282 "AssumeRole",
1283 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1284 );
1285 let resp = svc.handle(req).await.unwrap();
1286 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1287 assert!(body.contains("<AccessKeyId>"), "{body}");
1288 }
1289
1290 #[tokio::test]
1291 async fn assume_role_blocked_when_principal_not_in_trust_policy() {
1292 let (svc, state) = make_sts_service();
1295 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::999999999999:root"},"Action":"sts:AssumeRole"}]}"#;
1296 let role_arn = create_role_in_state_with_trust(&state, "other-account", trust);
1297 let req = sts_request(
1298 "AssumeRole",
1299 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1300 );
1301 let err = match svc.handle(req).await {
1302 Err(e) => e,
1303 Ok(_) => panic!("expected AccessDenied when caller account not in trust policy"),
1304 };
1305 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1306 }
1307
1308 #[tokio::test]
1311 async fn assume_role_blocked_when_external_id_required_but_missing() {
1312 let (svc, state) = make_sts_service();
1313 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"hello"}}}]}"#;
1314 let role_arn = create_role_in_state_with_trust(&state, "ext-required", trust);
1315 let req = sts_request(
1316 "AssumeRole",
1317 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1318 );
1319 let err = match svc.handle(req).await {
1320 Err(e) => e,
1321 Ok(_) => panic!("expected AccessDenied when ExternalId required but missing"),
1322 };
1323 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1324 }
1325
1326 #[tokio::test]
1327 async fn assume_role_succeeds_with_correct_external_id() {
1328 let (svc, state) = make_sts_service();
1329 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"hello"}}}]}"#;
1330 let role_arn = create_role_in_state_with_trust(&state, "ext-ok", trust);
1331 let req = sts_request(
1332 "AssumeRole",
1333 vec![
1334 ("RoleArn", &role_arn),
1335 ("RoleSessionName", "sess"),
1336 ("ExternalId", "hello"),
1337 ],
1338 );
1339 svc.handle(req).await.unwrap();
1340 }
1341
1342 #[tokio::test]
1345 async fn assume_role_blocked_when_mfa_required_but_not_present() {
1346 let (svc, state) = make_sts_service();
1350 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}"#;
1351 let role_arn = create_role_in_state_with_trust(&state, "mfa-required", trust);
1352 let req = sts_request(
1353 "AssumeRole",
1354 vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1355 );
1356 let err = match svc.handle(req).await {
1357 Err(e) => e,
1358 Ok(_) => panic!("expected AccessDenied when MFA required but not supplied"),
1359 };
1360 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1361 }
1362
1363 #[tokio::test]
1364 async fn assume_role_succeeds_with_mfa_supplied() {
1365 let (svc, state) = make_sts_service();
1370 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}"#;
1371 let role_arn = create_role_in_state_with_trust(&state, "mfa-ok", trust);
1372 let req = sts_request(
1373 "AssumeRole",
1374 vec![
1375 ("RoleArn", &role_arn),
1376 ("RoleSessionName", "sess"),
1377 ("SerialNumber", "arn:aws:iam::123456789012:mfa/alice"),
1378 ("TokenCode", "123456"),
1379 ],
1380 );
1381 let resp = svc.handle(req).await.unwrap();
1382 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1383 assert!(body.contains("<AccessKeyId>"), "{body}");
1384 let states = state.read();
1387 let s = states.get("123456789012").unwrap();
1388 let any_mfa = s.sts_temp_credentials.values().any(|c| c.mfa_present);
1389 assert!(
1390 any_mfa,
1391 "expected at least one minted credential with mfa_present=true"
1392 );
1393 }
1394
1395 #[tokio::test]
1396 async fn assume_role_with_mfa_resolved_credential_drives_iam_evaluator() {
1397 use crate::credential_resolver::IamCredentialResolver;
1405 use crate::evaluator::{
1406 evaluate as eval_policies, EvalRequest, PolicyDocument, RequestContext,
1407 };
1408 use fakecloud_core::auth::{ConditionContext, CredentialResolver};
1409
1410 let (svc, state) = make_sts_service();
1411 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1412 let role_arn = create_role_in_state_with_trust(&state, "mfa-e2e", trust);
1413 let req = sts_request(
1414 "AssumeRole",
1415 vec![
1416 ("RoleArn", &role_arn),
1417 ("RoleSessionName", "ops"),
1418 ("SerialNumber", "arn:aws:iam::123456789012:mfa/alice"),
1419 ("TokenCode", "654321"),
1420 ],
1421 );
1422 let resp = svc.handle(req).await.unwrap();
1423 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1424 let access_key_id = body
1426 .split("<AccessKeyId>")
1427 .nth(1)
1428 .and_then(|s| s.split("</AccessKeyId>").next())
1429 .expect("response should contain AccessKeyId")
1430 .to_string();
1431 let resolver = IamCredentialResolver::new(state.clone());
1432 let resolved = resolver
1433 .resolve(&access_key_id)
1434 .expect("issued credential must resolve through the resolver");
1435 assert!(
1436 resolved.mfa_present,
1437 "F3: MFA flag must survive the resolver hop"
1438 );
1439 assert!(
1440 resolved.token_issued_at.is_some(),
1441 "F3: token_issued_at must be populated for STS sessions"
1442 );
1443
1444 let mut ctx: RequestContext = ConditionContext {
1448 aws_principal_arn: Some(resolved.principal.arn.clone()),
1449 aws_principal_account: Some(resolved.principal.account_id.clone()),
1450 aws_userid: Some(resolved.principal.user_id.clone()),
1451 aws_mfa_present: Some(resolved.mfa_present),
1452 aws_token_issue_time: resolved.token_issued_at,
1453 aws_federated_provider: resolved.federated_provider.clone(),
1454 ..Default::default()
1455 };
1456 if resolved.mfa_present {
1457 if let Some(issued) = resolved.token_issued_at {
1458 ctx.aws_mfa_age_seconds = Some(
1459 Utc::now()
1460 .signed_duration_since(issued)
1461 .num_seconds()
1462 .max(0),
1463 );
1464 }
1465 }
1466
1467 let policy = PolicyDocument::parse(
1468 r#"{"Version":"2012-10-17","Statement":[{
1469 "Effect":"Allow",
1470 "Action":"s3:GetObject",
1471 "Resource":"*",
1472 "Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}
1473 }]}"#,
1474 );
1475 let eval = EvalRequest {
1476 principal: &resolved.principal,
1477 action: "s3:GetObject".to_string(),
1478 resource: "arn:aws:s3:::secrets/k".to_string(),
1479 context: ctx,
1480 };
1481 let decision = eval_policies(&[policy], &eval);
1482 assert_eq!(
1483 decision,
1484 crate::evaluator::Decision::Allow,
1485 "F3: MFA-gated allow must fire when session was minted with MFA"
1486 );
1487
1488 let req_no_mfa = sts_request(
1491 "AssumeRole",
1492 vec![("RoleArn", &role_arn), ("RoleSessionName", "no-mfa")],
1493 );
1494 let resp_no_mfa = svc.handle(req_no_mfa).await.unwrap();
1495 let body_no_mfa = std::str::from_utf8(resp_no_mfa.body.expect_bytes()).unwrap();
1496 let akid_no_mfa = body_no_mfa
1497 .split("<AccessKeyId>")
1498 .nth(1)
1499 .and_then(|s| s.split("</AccessKeyId>").next())
1500 .unwrap()
1501 .to_string();
1502 let resolved_no_mfa = resolver.resolve(&akid_no_mfa).unwrap();
1503 assert!(!resolved_no_mfa.mfa_present);
1504 let policy2 = PolicyDocument::parse(
1505 r#"{"Version":"2012-10-17","Statement":[{
1506 "Effect":"Allow",
1507 "Action":"s3:GetObject",
1508 "Resource":"*",
1509 "Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}
1510 }]}"#,
1511 );
1512 let ctx2 = ConditionContext {
1513 aws_principal_arn: Some(resolved_no_mfa.principal.arn.clone()),
1514 aws_userid: Some(resolved_no_mfa.principal.user_id.clone()),
1515 aws_mfa_present: Some(resolved_no_mfa.mfa_present),
1516 aws_token_issue_time: resolved_no_mfa.token_issued_at,
1517 ..Default::default()
1518 };
1519 let eval2 = EvalRequest {
1520 principal: &resolved_no_mfa.principal,
1521 action: "s3:GetObject".to_string(),
1522 resource: "arn:aws:s3:::secrets/k".to_string(),
1523 context: ctx2,
1524 };
1525 assert_eq!(
1526 eval_policies(&[policy2], &eval2),
1527 crate::evaluator::Decision::ImplicitDeny,
1528 "F3: MFA-gated allow must NOT fire when session was minted without MFA"
1529 );
1530 }
1531
1532 #[tokio::test]
1533 async fn assume_role_with_saml_populates_federated_provider() {
1534 use crate::credential_resolver::IamCredentialResolver;
1537 use base64::Engine;
1538 use fakecloud_core::auth::CredentialResolver;
1539 let (svc, state) = make_sts_service();
1540 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
1541 let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
1542 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>"#;
1543 let saml_b64 = base64::engine::general_purpose::STANDARD.encode(saml_xml);
1544 let provider_arn = "arn:aws:iam::123456789012:saml-provider/idp";
1545 let req = sts_request(
1546 "AssumeRoleWithSAML",
1547 vec![
1548 ("RoleArn", &role_arn),
1549 ("PrincipalArn", provider_arn),
1550 ("SAMLAssertion", &saml_b64),
1551 ],
1552 );
1553 let resp = svc.handle(req).await.unwrap();
1554 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1555 let access_key_id = body
1556 .split("<AccessKeyId>")
1557 .nth(1)
1558 .and_then(|s| s.split("</AccessKeyId>").next())
1559 .unwrap()
1560 .to_string();
1561 let resolver = IamCredentialResolver::new(state.clone());
1562 let resolved = resolver.resolve(&access_key_id).unwrap();
1563 assert_eq!(
1564 resolved.federated_provider.as_deref(),
1565 Some(provider_arn),
1566 "AssumeRoleWithSAML must populate aws:FederatedProvider with the SAML provider ARN"
1567 );
1568 }
1569
1570 #[tokio::test]
1571 async fn assume_role_with_web_identity_populates_federated_provider() {
1572 use crate::credential_resolver::IamCredentialResolver;
1578 use fakecloud_core::auth::CredentialResolver;
1579 let (svc, state) = make_sts_service();
1580 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
1581 let role_arn = create_role_in_state_with_trust(&state, "oidc-role", trust);
1582 let req = sts_request(
1583 "AssumeRoleWithWebIdentity",
1584 vec![
1585 ("RoleArn", &role_arn),
1586 ("RoleSessionName", "oidc-session"),
1587 ("WebIdentityToken", "fake-jwt-blob"),
1588 ("ProviderId", "accounts.google.com"),
1589 ],
1590 );
1591 let resp = svc.handle(req).await.unwrap();
1592 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1593 let access_key_id = body
1594 .split("<AccessKeyId>")
1595 .nth(1)
1596 .and_then(|s| s.split("</AccessKeyId>").next())
1597 .unwrap()
1598 .to_string();
1599 let resolver = IamCredentialResolver::new(state.clone());
1600 let resolved = resolver.resolve(&access_key_id).unwrap();
1601 assert_eq!(
1602 resolved.federated_provider.as_deref(),
1603 Some("accounts.google.com"),
1604 "AssumeRoleWithWebIdentity must carry ProviderId as aws:FederatedProvider"
1605 );
1606 }
1607
1608 #[tokio::test]
1609 async fn assume_role_userid_format_matches_aws() {
1610 use crate::credential_resolver::IamCredentialResolver;
1614 use fakecloud_core::auth::CredentialResolver;
1615 let (svc, state) = make_sts_service();
1616 let role_arn = create_role_in_state(&state, "userid-role");
1617 let req = sts_request(
1618 "AssumeRole",
1619 vec![("RoleArn", &role_arn), ("RoleSessionName", "carol")],
1620 );
1621 let resp = svc.handle(req).await.unwrap();
1622 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1623 let access_key_id = body
1624 .split("<AccessKeyId>")
1625 .nth(1)
1626 .and_then(|s| s.split("</AccessKeyId>").next())
1627 .unwrap()
1628 .to_string();
1629 let resolver = IamCredentialResolver::new(state);
1630 let resolved = resolver.resolve(&access_key_id).unwrap();
1631 let uid = &resolved.principal.user_id;
1632 assert!(
1633 uid.contains(':'),
1634 "assumed-role userid must be `<role-id>:<RoleSessionName>`, got `{uid}`"
1635 );
1636 assert!(
1637 uid.ends_with(":carol"),
1638 "assumed-role userid must end with the RoleSessionName, got `{uid}`"
1639 );
1640 }
1641
1642 #[tokio::test]
1645 async fn assume_service_linked_role_blocked_when_caller_not_matching_service() {
1646 let (svc, state) = make_sts_service();
1647 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
1648 let arn = fakecloud_aws::arn::Arn::global(
1649 "iam",
1650 "123456789012",
1651 "role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
1652 )
1653 .to_string();
1654 {
1655 let mut accounts = state.write();
1656 let s = accounts.get_or_create("123456789012");
1657 s.roles.insert(
1658 "AWSServiceRoleForECS".to_string(),
1659 crate::state::IamRole {
1660 role_name: "AWSServiceRoleForECS".to_string(),
1661 role_id: "AROASLRECS".to_string(),
1662 arn: arn.clone(),
1663 path: "/aws-service-role/ecs.amazonaws.com/".to_string(),
1664 assume_role_policy_document: trust.to_string(),
1665 created_at: Utc::now(),
1666 description: None,
1667 max_session_duration: 3600,
1668 tags: Vec::new(),
1669 permissions_boundary: None,
1670 },
1671 );
1672 }
1673 let req = sts_request(
1674 "AssumeRole",
1675 vec![("RoleArn", &arn), ("RoleSessionName", "sess")],
1676 );
1677 let err = match svc.handle(req).await {
1678 Err(e) => e,
1679 Ok(_) => {
1680 panic!("expected AccessDenied for service-linked role with non-service caller")
1681 }
1682 };
1683 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1684 }
1685
1686 #[tokio::test]
1687 async fn service_linked_role_rejects_non_service_caller() {
1688 let (svc, state) = make_sts_service();
1689 let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1690 let arn = fakecloud_aws::arn::Arn::global(
1691 "iam",
1692 "123456789012",
1693 "role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForELB",
1694 )
1695 .to_string();
1696 {
1697 let mut accounts = state.write();
1698 let s = accounts.get_or_create("123456789012");
1699 s.roles.insert(
1700 "AWSServiceRoleForELB".to_string(),
1701 crate::state::IamRole {
1702 role_name: "AWSServiceRoleForELB".to_string(),
1703 role_id: "AROASLR".to_string(),
1704 arn: arn.clone(),
1705 path: "/aws-service-role/elasticloadbalancing.amazonaws.com/".to_string(),
1706 assume_role_policy_document: trust.to_string(),
1707 created_at: Utc::now(),
1708 description: None,
1709 max_session_duration: 3600,
1710 tags: Vec::new(),
1711 permissions_boundary: None,
1712 },
1713 );
1714 }
1715 let req = sts_request(
1716 "AssumeRole",
1717 vec![("RoleArn", &arn), ("RoleSessionName", "sess")],
1718 );
1719 let err = match svc.handle(req).await {
1720 Err(e) => e,
1721 Ok(_) => panic!("expected AccessDenied for non-service caller"),
1722 };
1723 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1724 }
1725
1726 #[tokio::test]
1729 async fn unsupported_sts_action() {
1730 let (svc, _) = make_sts_service();
1731 let req = sts_request("BogusAction", vec![]);
1732 assert!(svc.handle(req).await.is_err());
1733 }
1734
1735 #[tokio::test]
1736 async fn assume_role_missing_role_arn_errors() {
1737 let (svc, _) = make_sts_service();
1738 let req = sts_request("AssumeRole", vec![("RoleSessionName", "sess")]);
1739 assert!(svc.assume_role(&req).is_err());
1740 }
1741
1742 #[tokio::test]
1743 async fn assume_role_with_web_identity_missing_token_errors() {
1744 let (svc, _) = make_sts_service();
1745 let req = sts_request(
1746 "AssumeRoleWithWebIdentity",
1747 vec![
1748 ("RoleArn", "arn:aws:iam::123:role/r"),
1749 ("RoleSessionName", "s"),
1750 ],
1751 );
1752 assert!(svc.assume_role_with_web_identity(&req).is_err());
1753 }
1754
1755 #[tokio::test]
1756 async fn assume_role_with_saml_missing_assertion_errors() {
1757 let (svc, _) = make_sts_service();
1758 let req = sts_request(
1759 "AssumeRoleWithSAML",
1760 vec![
1761 ("RoleArn", "arn:aws:iam::123:role/r"),
1762 ("PrincipalArn", "arn:aws:iam::123:saml-provider/p"),
1763 ],
1764 );
1765 assert!(svc.assume_role_with_saml(&req).is_err());
1766 }
1767
1768 #[tokio::test]
1769 async fn get_session_token_returns_ok() {
1770 let (svc, _) = make_sts_service();
1771 let req = sts_request("GetSessionToken", vec![]);
1772 let resp = svc.get_session_token(&req).unwrap();
1773 assert_eq!(resp.status, http::StatusCode::OK);
1774 }
1775
1776 #[tokio::test]
1777 async fn get_federation_token_returns_ok() {
1778 let (svc, _) = make_sts_service();
1779 let req = sts_request("GetFederationToken", vec![("Name", "test-user")]);
1780 let resp = svc.get_federation_token(&req).unwrap();
1781 assert_eq!(resp.status, http::StatusCode::OK);
1782 }
1783
1784 #[tokio::test]
1785 async fn get_federation_token_missing_name_errors() {
1786 let (svc, _) = make_sts_service();
1787 let req = sts_request("GetFederationToken", vec![]);
1788 assert!(svc.get_federation_token(&req).is_err());
1789 }
1790
1791 #[tokio::test]
1794 async fn assume_root_with_account_id_succeeds() {
1795 let (svc, state) = make_sts_service();
1796 state
1798 .write()
1799 .get_or_create("123456789012")
1800 .organizations_root_sessions = true;
1801 let req = sts_request(
1802 "AssumeRoot",
1803 vec![
1804 ("TargetPrincipal", "111122223333"),
1805 (
1806 "TaskPolicyArn.arn",
1807 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1808 ),
1809 ],
1810 );
1811 let resp = svc.assume_root(&req).unwrap();
1812 assert_eq!(resp.status, http::StatusCode::OK);
1813 let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1814 assert!(body.contains("AccessKeyId"), "{body}");
1815 }
1816
1817 #[tokio::test]
1818 async fn assume_root_with_arn_succeeds() {
1819 let (svc, state) = make_sts_service();
1820 state
1822 .write()
1823 .get_or_create("123456789012")
1824 .organizations_root_sessions = true;
1825 let req = sts_request(
1826 "AssumeRoot",
1827 vec![
1828 ("TargetPrincipal", "arn:aws:iam::444455556666:root"),
1829 (
1830 "TaskPolicyArn.arn",
1831 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1832 ),
1833 ("DurationSeconds", "600"),
1834 ("SourceIdentity", "alice"),
1835 ],
1836 );
1837 let resp = svc.assume_root(&req).unwrap();
1838 let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1839 assert!(
1840 body.contains("<SourceIdentity>alice</SourceIdentity>"),
1841 "{body}"
1842 );
1843 }
1844
1845 #[tokio::test]
1846 async fn assume_root_denied_without_root_sessions() {
1847 let (svc, _) = make_sts_service();
1851 let req = sts_request(
1852 "AssumeRoot",
1853 vec![
1854 ("TargetPrincipal", "arn:aws:iam::444455556666:root"),
1855 (
1856 "TaskPolicyArn.arn",
1857 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1858 ),
1859 ],
1860 );
1861 let err = svc
1862 .assume_root(&req)
1863 .err()
1864 .expect("AssumeRoot must be denied without RootSessions");
1865 assert_eq!(err.code(), "AccessDeniedException");
1866 }
1867
1868 #[tokio::test]
1869 async fn assume_root_same_account_succeeds_without_root_sessions() {
1870 let (svc, _) = make_sts_service();
1874 let req = sts_request(
1875 "AssumeRoot",
1876 vec![
1877 ("TargetPrincipal", "123456789012"),
1878 (
1879 "TaskPolicyArn.arn",
1880 "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1881 ),
1882 ],
1883 );
1884 let resp = svc.assume_root(&req).unwrap();
1885 assert_eq!(resp.status, http::StatusCode::OK);
1886 let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1887 assert!(body.contains("AccessKeyId"), "{body}");
1888 }
1889
1890 #[tokio::test]
1891 async fn assume_root_missing_task_policy_rejected() {
1892 let (svc, _) = make_sts_service();
1898 let req = sts_request("AssumeRoot", vec![("TargetPrincipal", "111122223333")]);
1899 let err = svc
1900 .assume_root(&req)
1901 .err()
1902 .expect("missing TaskPolicyArn must fail");
1903 assert_eq!(err.code(), "ExpiredTokenException");
1904 }
1905
1906 #[tokio::test]
1907 async fn assume_root_target_principal_below_min_length_rejected() {
1908 let (svc, _) = make_sts_service();
1911 let req = sts_request(
1912 "AssumeRoot",
1913 vec![
1914 ("TargetPrincipal", "not-an-id"),
1915 ("TaskPolicyArn.arn", "arn:aws:iam::aws:policy/X"),
1916 ],
1917 );
1918 let err = svc
1919 .assume_root(&req)
1920 .err()
1921 .expect("short TargetPrincipal must fail");
1922 assert_eq!(err.code(), "ExpiredTokenException");
1923 }
1924
1925 #[tokio::test]
1926 async fn assume_root_duration_above_max_rejected() {
1927 let (svc, _) = make_sts_service();
1931 let req = sts_request(
1932 "AssumeRoot",
1933 vec![
1934 ("TargetPrincipal", "111122223333"),
1935 ("TaskPolicyArn.arn", "arn:aws:iam::aws:policy/X"),
1936 ("DurationSeconds", "1800"),
1937 ],
1938 );
1939 let err = svc
1940 .assume_root(&req)
1941 .err()
1942 .expect("out-of-range DurationSeconds must fail");
1943 assert_eq!(err.code(), "ExpiredTokenException");
1944 }
1945}