Skip to main content

fakecloud_iam/
xml_responses.rs

1use base64::Engine;
2
3use crate::state::{IamAccessKey, IamPolicy, IamRole, IamUser};
4
5use fakecloud_aws::xml::xml_escape;
6
7/// URL-encode a policy document for XML embedding (like AWS does).
8fn url_encode_policy(s: &str) -> String {
9    let mut result = String::new();
10    for byte in s.bytes() {
11        match byte {
12            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
13                result.push(byte as char);
14            }
15            _ => {
16                use std::fmt::Write;
17                write!(result, "%{:02X}", byte).unwrap();
18            }
19        }
20    }
21    result
22}
23
24fn tags_xml(tags: &[crate::state::Tag]) -> String {
25    if tags.is_empty() {
26        return String::new();
27    }
28    tags.iter()
29        .map(|t| {
30            format!(
31                "        <member>\n          <Key>{}</Key>\n          <Value>{}</Value>\n        </member>",
32                xml_escape(&t.key),
33                xml_escape(&t.value)
34            )
35        })
36        .collect::<Vec<_>>()
37        .join("\n")
38}
39
40fn user_xml(user: &IamUser) -> String {
41    let tags_section = if user.tags.is_empty() {
42        String::new()
43    } else {
44        let tags_members = tags_xml(&user.tags);
45        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
46    };
47
48    let pb_section = user
49        .permissions_boundary
50        .as_ref()
51        .map(|pb| {
52            format!(
53                "\n      <PermissionsBoundary>\n        <PermissionsBoundaryType>PermissionsBoundaryPolicy</PermissionsBoundaryType>\n        <PermissionsBoundaryArn>{pb}</PermissionsBoundaryArn>\n      </PermissionsBoundary>"
54            )
55        })
56        .unwrap_or_default();
57
58    format!(
59        r#"    <User>
60      <Path>{path}</Path>
61      <UserName>{name}</UserName>
62      <UserId>{id}</UserId>
63      <Arn>{arn}</Arn>
64      <CreateDate>{date}</CreateDate>{tags_section}{pb_section}
65    </User>"#,
66        path = user.path,
67        name = user.user_name,
68        id = user.user_id,
69        arn = user.arn,
70        date = user.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
71    )
72}
73
74fn role_xml(role: &IamRole) -> String {
75    let tags_section = if role.tags.is_empty() {
76        String::new()
77    } else {
78        let tags_members = tags_xml(&role.tags);
79        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
80    };
81
82    let pb_section = role
83        .permissions_boundary
84        .as_ref()
85        .map(|pb| {
86            format!(
87                "\n      <PermissionsBoundary>\n        <PermissionsBoundaryType>PermissionsBoundaryPolicy</PermissionsBoundaryType>\n        <PermissionsBoundaryArn>{pb}</PermissionsBoundaryArn>\n      </PermissionsBoundary>"
88            )
89        })
90        .unwrap_or_default();
91
92    let description_section = match &role.description {
93        Some(desc) => format!("\n      <Description>{}</Description>", xml_escape(desc)),
94        None => String::new(),
95    };
96
97    format!(
98        r#"    <Role>
99      <Path>{path}</Path>
100      <RoleName>{name}</RoleName>
101      <RoleId>{id}</RoleId>
102      <Arn>{arn}</Arn>
103      <CreateDate>{date}</CreateDate>
104      <AssumeRolePolicyDocument>{policy}</AssumeRolePolicyDocument>{description_section}
105      <MaxSessionDuration>{max_session}</MaxSessionDuration>
106      <RoleLastUsed/>{tags_section}{pb_section}
107    </Role>"#,
108        path = role.path,
109        name = role.role_name,
110        id = role.role_id,
111        arn = role.arn,
112        date = role.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
113        policy = url_encode_policy(&role.assume_role_policy_document),
114        max_session = role.max_session_duration,
115    )
116}
117
118pub fn create_user_response(user: &IamUser, request_id: &str) -> String {
119    let user_xml = user_xml(user);
120    format!(
121        r#"<?xml version="1.0" encoding="UTF-8"?>
122<CreateUserResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
123  <CreateUserResult>
124{user_xml}
125  </CreateUserResult>
126  <ResponseMetadata>
127    <RequestId>{request_id}</RequestId>
128  </ResponseMetadata>
129</CreateUserResponse>"#,
130    )
131}
132
133pub fn get_user_response(user: &IamUser, request_id: &str) -> String {
134    let user_xml = user_xml(user);
135    format!(
136        r#"<?xml version="1.0" encoding="UTF-8"?>
137<GetUserResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
138  <GetUserResult>
139{user_xml}
140  </GetUserResult>
141  <ResponseMetadata>
142    <RequestId>{request_id}</RequestId>
143  </ResponseMetadata>
144</GetUserResponse>"#,
145    )
146}
147
148pub fn list_users_response(
149    users: &[IamUser],
150    is_truncated: bool,
151    marker: Option<&str>,
152    request_id: &str,
153) -> String {
154    let members: String = users
155        .iter()
156        .map(|u| {
157            // ListUsers never includes Tags or PermissionsBoundary (AWS behavior)
158            format!(
159                r#"      <member>
160        <Path>{path}</Path>
161        <UserName>{name}</UserName>
162        <UserId>{id}</UserId>
163        <Arn>{arn}</Arn>
164        <CreateDate>{date}</CreateDate>
165      </member>"#,
166                path = u.path,
167                name = u.user_name,
168                id = u.user_id,
169                arn = u.arn,
170                date = u.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
171            )
172        })
173        .collect::<Vec<_>>()
174        .join("\n");
175
176    let marker_section = if let Some(m) = marker {
177        format!("\n    <Marker>{}</Marker>", xml_escape(m))
178    } else {
179        String::new()
180    };
181
182    format!(
183        r#"<?xml version="1.0" encoding="UTF-8"?>
184<ListUsersResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
185  <ListUsersResult>
186    <IsTruncated>{is_truncated}</IsTruncated>{marker_section}
187    <Users>
188{members}
189    </Users>
190  </ListUsersResult>
191  <ResponseMetadata>
192    <RequestId>{request_id}</RequestId>
193  </ResponseMetadata>
194</ListUsersResponse>"#,
195    )
196}
197
198pub fn create_access_key_response(key: &IamAccessKey, request_id: &str) -> String {
199    format!(
200        r#"<?xml version="1.0" encoding="UTF-8"?>
201<CreateAccessKeyResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
202  <CreateAccessKeyResult>
203    <AccessKey>
204      <UserName>{user}</UserName>
205      <AccessKeyId>{key_id}</AccessKeyId>
206      <Status>{status}</Status>
207      <SecretAccessKey>{secret}</SecretAccessKey>
208      <CreateDate>{date}</CreateDate>
209    </AccessKey>
210  </CreateAccessKeyResult>
211  <ResponseMetadata>
212    <RequestId>{request_id}</RequestId>
213  </ResponseMetadata>
214</CreateAccessKeyResponse>"#,
215        user = key.user_name,
216        key_id = key.access_key_id,
217        status = key.status,
218        secret = key.secret_access_key,
219        date = key.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
220    )
221}
222
223pub fn list_access_keys_response(
224    keys: &[IamAccessKey],
225    user_name: &str,
226    is_truncated: bool,
227    marker: Option<&str>,
228    request_id: &str,
229) -> String {
230    let members: String = keys
231        .iter()
232        .map(|k| {
233            format!(
234                r#"      <member>
235        <UserName>{user}</UserName>
236        <AccessKeyId>{key_id}</AccessKeyId>
237        <Status>{status}</Status>
238        <CreateDate>{date}</CreateDate>
239      </member>"#,
240                user = k.user_name,
241                key_id = k.access_key_id,
242                status = k.status,
243                date = k.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
244            )
245        })
246        .collect::<Vec<_>>()
247        .join("\n");
248
249    let marker_section = if let Some(m) = marker {
250        format!("\n    <Marker>{}</Marker>", xml_escape(m))
251    } else {
252        String::new()
253    };
254
255    format!(
256        r#"<?xml version="1.0" encoding="UTF-8"?>
257<ListAccessKeysResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
258  <ListAccessKeysResult>
259    <UserName>{user_name}</UserName>
260    <IsTruncated>{is_truncated}</IsTruncated>{marker_section}
261    <AccessKeyMetadata>
262{members}
263    </AccessKeyMetadata>
264  </ListAccessKeysResult>
265  <ResponseMetadata>
266    <RequestId>{request_id}</RequestId>
267  </ResponseMetadata>
268</ListAccessKeysResponse>"#,
269    )
270}
271
272pub fn create_role_response(role: &IamRole, request_id: &str) -> String {
273    let role_xml = role_xml(role);
274    format!(
275        r#"<?xml version="1.0" encoding="UTF-8"?>
276<CreateRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
277  <CreateRoleResult>
278{role_xml}
279  </CreateRoleResult>
280  <ResponseMetadata>
281    <RequestId>{request_id}</RequestId>
282  </ResponseMetadata>
283</CreateRoleResponse>"#,
284    )
285}
286
287pub fn get_role_response(role: &IamRole, request_id: &str) -> String {
288    let role_xml = role_xml(role);
289    format!(
290        r#"<?xml version="1.0" encoding="UTF-8"?>
291<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
292  <GetRoleResult>
293{role_xml}
294  </GetRoleResult>
295  <ResponseMetadata>
296    <RequestId>{request_id}</RequestId>
297  </ResponseMetadata>
298</GetRoleResponse>"#,
299    )
300}
301
302pub fn list_roles_response(roles: &[IamRole], request_id: &str) -> String {
303    let members: String = roles
304        .iter()
305        .map(|r| {
306            // ListRoles does NOT include Tags, PermissionsBoundary, or RoleLastUsed
307            let description_section = match &r.description {
308                Some(desc) => format!("\n        <Description>{}</Description>", xml_escape(desc)),
309                None => String::new(),
310            };
311            format!(
312                r#"      <member>
313        <Path>{path}</Path>
314        <RoleName>{name}</RoleName>
315        <RoleId>{id}</RoleId>
316        <Arn>{arn}</Arn>
317        <CreateDate>{date}</CreateDate>
318        <AssumeRolePolicyDocument>{policy}</AssumeRolePolicyDocument>{description_section}
319        <MaxSessionDuration>{max_session}</MaxSessionDuration>
320      </member>"#,
321                path = r.path,
322                name = r.role_name,
323                id = r.role_id,
324                arn = r.arn,
325                date = r.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
326                policy = url_encode_policy(&r.assume_role_policy_document),
327                max_session = r.max_session_duration,
328            )
329        })
330        .collect::<Vec<_>>()
331        .join("\n");
332
333    format!(
334        r#"<?xml version="1.0" encoding="UTF-8"?>
335<ListRolesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
336  <ListRolesResult>
337    <IsTruncated>false</IsTruncated>
338    <Roles>
339{members}
340    </Roles>
341  </ListRolesResult>
342  <ResponseMetadata>
343    <RequestId>{request_id}</RequestId>
344  </ResponseMetadata>
345</ListRolesResponse>"#,
346    )
347}
348
349pub fn list_roles_response_paginated(
350    roles: &[IamRole],
351    is_truncated: bool,
352    marker: Option<&str>,
353    request_id: &str,
354) -> String {
355    let members: String = roles
356        .iter()
357        .map(|r| {
358            // ListRoles does NOT include Tags, PermissionsBoundary, or RoleLastUsed
359            let description_section = match &r.description {
360                Some(desc) => format!("\n        <Description>{}</Description>", xml_escape(desc)),
361                None => String::new(),
362            };
363            format!(
364                r#"      <member>
365        <Path>{path}</Path>
366        <RoleName>{name}</RoleName>
367        <RoleId>{id}</RoleId>
368        <Arn>{arn}</Arn>
369        <CreateDate>{date}</CreateDate>
370        <AssumeRolePolicyDocument>{policy}</AssumeRolePolicyDocument>{description_section}
371        <MaxSessionDuration>{max_session}</MaxSessionDuration>
372      </member>"#,
373                path = r.path,
374                name = r.role_name,
375                id = r.role_id,
376                arn = r.arn,
377                date = r.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
378                policy = url_encode_policy(&r.assume_role_policy_document),
379                max_session = r.max_session_duration,
380            )
381        })
382        .collect::<Vec<_>>()
383        .join("\n");
384
385    let marker_section = if let Some(m) = marker {
386        format!("\n    <Marker>{}</Marker>", xml_escape(m))
387    } else {
388        String::new()
389    };
390
391    format!(
392        r#"<?xml version="1.0" encoding="UTF-8"?>
393<ListRolesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
394  <ListRolesResult>
395    <IsTruncated>{is_truncated}</IsTruncated>{marker_section}
396    <Roles>
397{members}
398    </Roles>
399  </ListRolesResult>
400  <ResponseMetadata>
401    <RequestId>{request_id}</RequestId>
402  </ResponseMetadata>
403</ListRolesResponse>"#,
404    )
405}
406
407pub fn create_policy_response(policy: &IamPolicy, request_id: &str) -> String {
408    let tags_section = if policy.tags.is_empty() {
409        String::new()
410    } else {
411        let tags_members = tags_xml(&policy.tags);
412        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
413    };
414
415    format!(
416        r#"<?xml version="1.0" encoding="UTF-8"?>
417<CreatePolicyResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
418  <CreatePolicyResult>
419    <Policy>
420      <PolicyName>{name}</PolicyName>
421      <PolicyId>{id}</PolicyId>
422      <Arn>{arn}</Arn>
423      <Path>{path}</Path>
424      <DefaultVersionId>{default_version}</DefaultVersionId>
425      <AttachmentCount>{attachment_count}</AttachmentCount>
426      <IsAttachable>true</IsAttachable>
427      <CreateDate>{date}</CreateDate>{tags_section}
428    </Policy>
429  </CreatePolicyResult>
430  <ResponseMetadata>
431    <RequestId>{request_id}</RequestId>
432  </ResponseMetadata>
433</CreatePolicyResponse>"#,
434        name = policy.policy_name,
435        id = policy.policy_id,
436        arn = policy.arn,
437        path = policy.path,
438        default_version = policy.default_version_id,
439        attachment_count = policy.attachment_count,
440        date = policy.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
441    )
442}
443
444pub fn list_policies_response(
445    policies: &[IamPolicy],
446    is_truncated: bool,
447    marker: Option<&str>,
448    request_id: &str,
449) -> String {
450    let members: String = policies
451        .iter()
452        .map(|p| {
453            // ListPolicies never includes Tags or Description (AWS behavior)
454            format!(
455                r#"      <member>
456        <PolicyName>{name}</PolicyName>
457        <PolicyId>{id}</PolicyId>
458        <Arn>{arn}</Arn>
459        <Path>{path}</Path>
460        <DefaultVersionId>{default_version}</DefaultVersionId>
461        <AttachmentCount>{attachment_count}</AttachmentCount>
462        <IsAttachable>true</IsAttachable>
463        <CreateDate>{date}</CreateDate>
464      </member>"#,
465                name = p.policy_name,
466                id = p.policy_id,
467                arn = p.arn,
468                path = p.path,
469                default_version = p.default_version_id,
470                attachment_count = p.attachment_count,
471                date = p.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
472            )
473        })
474        .collect::<Vec<_>>()
475        .join("\n");
476
477    let marker_section = if let Some(m) = marker {
478        format!("\n    <Marker>{}</Marker>", xml_escape(m))
479    } else {
480        String::new()
481    };
482
483    format!(
484        r#"<?xml version="1.0" encoding="UTF-8"?>
485<ListPoliciesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
486  <ListPoliciesResult>
487    <IsTruncated>{is_truncated}</IsTruncated>{marker_section}
488    <Policies>
489{members}
490    </Policies>
491  </ListPoliciesResult>
492  <ResponseMetadata>
493    <RequestId>{request_id}</RequestId>
494  </ResponseMetadata>
495</ListPoliciesResponse>"#,
496    )
497}
498
499pub fn get_policy_response(policy: &IamPolicy, request_id: &str) -> String {
500    // GetPolicy always includes Tags, even if empty (AWS behavior)
501    let tags_section = if policy.tags.is_empty() {
502        "\n      <Tags/>".to_string()
503    } else {
504        let tags_members = tags_xml(&policy.tags);
505        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
506    };
507
508    let description_section = if policy.description.is_empty() {
509        String::new()
510    } else {
511        format!(
512            "\n      <Description>{}</Description>",
513            xml_escape(&policy.description)
514        )
515    };
516
517    format!(
518        r#"<?xml version="1.0" encoding="UTF-8"?>
519<GetPolicyResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
520  <GetPolicyResult>
521    <Policy>
522      <PolicyName>{name}</PolicyName>
523      <PolicyId>{id}</PolicyId>
524      <Arn>{arn}</Arn>
525      <Path>{path}</Path>
526      <DefaultVersionId>{default_version}</DefaultVersionId>
527      <AttachmentCount>{attachment_count}</AttachmentCount>
528      <IsAttachable>true</IsAttachable>
529      <CreateDate>{date}</CreateDate>{description_section}{tags_section}
530    </Policy>
531  </GetPolicyResult>
532  <ResponseMetadata>
533    <RequestId>{request_id}</RequestId>
534  </ResponseMetadata>
535</GetPolicyResponse>"#,
536        name = policy.policy_name,
537        id = policy.policy_id,
538        arn = policy.arn,
539        path = policy.path,
540        default_version = policy.default_version_id,
541        attachment_count = policy.attachment_count,
542        date = policy.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
543    )
544}
545
546pub fn list_role_policies_response(policy_names: &[String], request_id: &str) -> String {
547    let members: String = policy_names
548        .iter()
549        .map(|name| format!("      <member>{name}</member>"))
550        .collect::<Vec<_>>()
551        .join("\n");
552
553    format!(
554        r#"<?xml version="1.0" encoding="UTF-8"?>
555<ListRolePoliciesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
556  <ListRolePoliciesResult>
557    <IsTruncated>false</IsTruncated>
558    <PolicyNames>
559{members}
560    </PolicyNames>
561  </ListRolePoliciesResult>
562  <ResponseMetadata>
563    <RequestId>{request_id}</RequestId>
564  </ResponseMetadata>
565</ListRolePoliciesResponse>"#,
566    )
567}
568
569pub fn get_caller_identity_response(
570    account_id: &str,
571    arn: &str,
572    user_id: &str,
573    request_id: &str,
574) -> String {
575    format!(
576        r#"<?xml version="1.0" encoding="UTF-8"?>
577<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
578  <GetCallerIdentityResult>
579    <Arn>{arn}</Arn>
580    <UserId>{user_id}</UserId>
581    <Account>{account_id}</Account>
582  </GetCallerIdentityResult>
583  <ResponseMetadata>
584    <RequestId>{request_id}</RequestId>
585  </ResponseMetadata>
586</GetCallerIdentityResponse>"#,
587    )
588}
589
590/// Pre-generated STS credentials to be returned in XML responses.
591pub struct StsCredentials {
592    pub access_key_id: String,
593    pub secret_access_key: String,
594    pub session_token: String,
595}
596
597impl StsCredentials {
598    pub fn generate() -> Self {
599        Self {
600            access_key_id: generate_access_key_id(),
601            secret_access_key: generate_secret_access_key(),
602            session_token: generate_session_token(),
603        }
604    }
605}
606
607/// Inputs shared by all assume-role variants used to build an STS XML response.
608pub struct AssumedRoleInfo<'a> {
609    pub role_arn: &'a str,
610    pub role_session_name: &'a str,
611    pub assumed_role_id: &'a str,
612    pub account_id: &'a str,
613    pub partition: &'a str,
614    pub creds: &'a StsCredentials,
615    pub expiration: &'a str,
616    pub request_id: &'a str,
617}
618
619impl AssumedRoleInfo<'_> {
620    fn assumed_role_arn(&self) -> String {
621        let role_name = self.role_arn.rsplit('/').next().unwrap_or("unknown");
622        format!(
623            "arn:{}:sts::{}:assumed-role/{}/{}",
624            self.partition, self.account_id, role_name, self.role_session_name
625        )
626    }
627}
628
629pub fn assume_role_response(info: &AssumedRoleInfo<'_>) -> String {
630    let assumed_role_arn = info.assumed_role_arn();
631    format!(
632        r#"<?xml version="1.0" encoding="UTF-8"?>
633<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
634  <AssumeRoleResult>
635    <Credentials>
636      <AccessKeyId>{access_key_id}</AccessKeyId>
637      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
638      <SessionToken>{session_token}</SessionToken>
639      <Expiration>{expiration}</Expiration>
640    </Credentials>
641    <AssumedRoleUser>
642      <AssumedRoleId>{role_id}:{session}</AssumedRoleId>
643      <Arn>{assumed_role_arn}</Arn>
644    </AssumedRoleUser>
645  </AssumeRoleResult>
646  <ResponseMetadata>
647    <RequestId>{request_id}</RequestId>
648  </ResponseMetadata>
649</AssumeRoleResponse>"#,
650        access_key_id = info.creds.access_key_id,
651        secret_access_key = info.creds.secret_access_key,
652        session_token = info.creds.session_token,
653        role_id = info.assumed_role_id,
654        assumed_role_arn = assumed_role_arn,
655        session = info.role_session_name,
656        expiration = info.expiration,
657        request_id = info.request_id,
658    )
659}
660
661pub fn assume_role_with_web_identity_response(info: &AssumedRoleInfo<'_>) -> String {
662    let assumed_role_arn = info.assumed_role_arn();
663    format!(
664        r#"<?xml version="1.0" encoding="UTF-8"?>
665<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
666  <AssumeRoleWithWebIdentityResult>
667    <Credentials>
668      <AccessKeyId>{access_key_id}</AccessKeyId>
669      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
670      <SessionToken>{session_token}</SessionToken>
671      <Expiration>{expiration}</Expiration>
672    </Credentials>
673    <AssumedRoleUser>
674      <AssumedRoleId>{assumed_role_id}:{session}</AssumedRoleId>
675      <Arn>{assumed_role_arn}</Arn>
676    </AssumedRoleUser>
677  </AssumeRoleWithWebIdentityResult>
678  <ResponseMetadata>
679    <RequestId>{request_id}</RequestId>
680  </ResponseMetadata>
681</AssumeRoleWithWebIdentityResponse>"#,
682        access_key_id = info.creds.access_key_id,
683        secret_access_key = info.creds.secret_access_key,
684        session_token = info.creds.session_token,
685        assumed_role_id = info.assumed_role_id,
686        assumed_role_arn = assumed_role_arn,
687        session = info.role_session_name,
688        expiration = info.expiration,
689        request_id = info.request_id,
690    )
691}
692
693pub fn assume_role_with_saml_response(info: &AssumedRoleInfo<'_>) -> String {
694    let assumed_role_arn = info.assumed_role_arn();
695    format!(
696        r#"<?xml version="1.0" encoding="UTF-8"?>
697<AssumeRoleWithSAMLResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
698  <AssumeRoleWithSAMLResult>
699    <Credentials>
700      <AccessKeyId>{access_key_id}</AccessKeyId>
701      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
702      <SessionToken>{session_token}</SessionToken>
703      <Expiration>{expiration}</Expiration>
704    </Credentials>
705    <AssumedRoleUser>
706      <AssumedRoleId>{assumed_role_id}:{session}</AssumedRoleId>
707      <Arn>{assumed_role_arn}</Arn>
708    </AssumedRoleUser>
709  </AssumeRoleWithSAMLResult>
710  <ResponseMetadata>
711    <RequestId>{request_id}</RequestId>
712  </ResponseMetadata>
713</AssumeRoleWithSAMLResponse>"#,
714        access_key_id = info.creds.access_key_id,
715        secret_access_key = info.creds.secret_access_key,
716        session_token = info.creds.session_token,
717        assumed_role_id = info.assumed_role_id,
718        assumed_role_arn = assumed_role_arn,
719        session = info.role_session_name,
720        expiration = info.expiration,
721        request_id = info.request_id,
722    )
723}
724
725pub fn get_session_token_response(
726    creds: &StsCredentials,
727    expiration: &str,
728    request_id: &str,
729) -> String {
730    let access_key_id = creds.access_key_id.as_str();
731    let secret_access_key = creds.secret_access_key.as_str();
732    let session_token = creds.session_token.as_str();
733
734    format!(
735        r#"<?xml version="1.0" encoding="UTF-8"?>
736<GetSessionTokenResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
737  <GetSessionTokenResult>
738    <Credentials>
739      <AccessKeyId>{access_key_id}</AccessKeyId>
740      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
741      <SessionToken>{session_token}</SessionToken>
742      <Expiration>{expiration}</Expiration>
743    </Credentials>
744  </GetSessionTokenResult>
745  <ResponseMetadata>
746    <RequestId>{request_id}</RequestId>
747  </ResponseMetadata>
748</GetSessionTokenResponse>"#,
749        access_key_id = access_key_id,
750        secret_access_key = secret_access_key,
751        session_token = session_token,
752        request_id = request_id,
753    )
754}
755
756pub fn get_federation_token_response(
757    creds: &StsCredentials,
758    name: &str,
759    account_id: &str,
760    partition: &str,
761    expiration: &str,
762    policy: Option<&str>,
763    request_id: &str,
764) -> String {
765    let access_key_id = creds.access_key_id.as_str();
766    let secret_access_key = creds.secret_access_key.as_str();
767    let session_token = creds.session_token.as_str();
768
769    let name = xml_escape(name);
770    let federated_user_arn = format!(
771        "arn:{}:sts::{}:federated-user/{}",
772        partition, account_id, name
773    );
774    let federated_user_id = format!("{}:{}", account_id, name);
775
776    let policy_section = if let Some(p) = policy {
777        format!(
778            "\n    <PackedPolicySize>6</PackedPolicySize>\n    <Policy>{}</Policy>",
779            xml_escape(p)
780        )
781    } else {
782        String::new()
783    };
784
785    format!(
786        r#"<?xml version="1.0" encoding="UTF-8"?>
787<GetFederationTokenResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
788  <GetFederationTokenResult>
789    <Credentials>
790      <AccessKeyId>{access_key_id}</AccessKeyId>
791      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
792      <SessionToken>{session_token}</SessionToken>
793      <Expiration>{expiration}</Expiration>
794    </Credentials>
795    <FederatedUser>
796      <FederatedUserId>{federated_user_id}</FederatedUserId>
797      <Arn>{federated_user_arn}</Arn>
798    </FederatedUser>{policy_section}
799  </GetFederationTokenResult>
800  <ResponseMetadata>
801    <RequestId>{request_id}</RequestId>
802  </ResponseMetadata>
803</GetFederationTokenResponse>"#,
804        access_key_id = access_key_id,
805        secret_access_key = secret_access_key,
806        session_token = session_token,
807        federated_user_arn = federated_user_arn,
808        federated_user_id = federated_user_id,
809        request_id = request_id,
810    )
811}
812
813pub fn assume_root_response(
814    creds: &StsCredentials,
815    expiration: &str,
816    source_identity: Option<&str>,
817    request_id: &str,
818) -> String {
819    let access_key_id = creds.access_key_id.as_str();
820    let secret_access_key = creds.secret_access_key.as_str();
821    let session_token = creds.session_token.as_str();
822    let source_identity_section = match source_identity {
823        Some(s) => format!("\n    <SourceIdentity>{}</SourceIdentity>", xml_escape(s)),
824        None => String::new(),
825    };
826    format!(
827        r#"<?xml version="1.0" encoding="UTF-8"?>
828<AssumeRootResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
829  <AssumeRootResult>
830    <Credentials>
831      <AccessKeyId>{access_key_id}</AccessKeyId>
832      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
833      <SessionToken>{session_token}</SessionToken>
834      <Expiration>{expiration}</Expiration>
835    </Credentials>{source_identity_section}
836  </AssumeRootResult>
837  <ResponseMetadata>
838    <RequestId>{request_id}</RequestId>
839  </ResponseMetadata>
840</AssumeRootResponse>"#,
841    )
842}
843
844pub fn get_web_identity_token_response(
845    web_identity_token: &str,
846    expiration: &str,
847    request_id: &str,
848) -> String {
849    format!(
850        r#"<?xml version="1.0" encoding="UTF-8"?>
851<GetWebIdentityTokenResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
852  <GetWebIdentityTokenResult>
853    <WebIdentityToken>{web_identity_token}</WebIdentityToken>
854    <Expiration>{expiration}</Expiration>
855  </GetWebIdentityTokenResult>
856  <ResponseMetadata>
857    <RequestId>{request_id}</RequestId>
858  </ResponseMetadata>
859</GetWebIdentityTokenResponse>"#,
860        web_identity_token = xml_escape(web_identity_token),
861        expiration = expiration,
862        request_id = request_id,
863    )
864}
865
866pub fn get_delegated_access_token_response(
867    creds: &StsCredentials,
868    expiration: &str,
869    assumed_principal: &str,
870    request_id: &str,
871) -> String {
872    let access_key_id = creds.access_key_id.as_str();
873    let secret_access_key = creds.secret_access_key.as_str();
874    let session_token = creds.session_token.as_str();
875    format!(
876        r#"<?xml version="1.0" encoding="UTF-8"?>
877<GetDelegatedAccessTokenResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
878  <GetDelegatedAccessTokenResult>
879    <Credentials>
880      <AccessKeyId>{access_key_id}</AccessKeyId>
881      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
882      <SessionToken>{session_token}</SessionToken>
883      <Expiration>{expiration}</Expiration>
884    </Credentials>
885    <PackedPolicySize>0</PackedPolicySize>
886    <AssumedPrincipal>{assumed_principal}</AssumedPrincipal>
887  </GetDelegatedAccessTokenResult>
888  <ResponseMetadata>
889    <RequestId>{request_id}</RequestId>
890  </ResponseMetadata>
891</GetDelegatedAccessTokenResponse>"#,
892        assumed_principal = xml_escape(assumed_principal),
893    )
894}
895
896pub fn decode_authorization_message_response(decoded_message: &str, request_id: &str) -> String {
897    format!(
898        r#"<?xml version="1.0" encoding="UTF-8"?>
899<DecodeAuthorizationMessageResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
900  <DecodeAuthorizationMessageResult>
901    <DecodedMessage>{decoded_message}</DecodedMessage>
902  </DecodeAuthorizationMessageResult>
903  <ResponseMetadata>
904    <RequestId>{request_id}</RequestId>
905  </ResponseMetadata>
906</DecodeAuthorizationMessageResponse>"#,
907        decoded_message = xml_escape(decoded_message),
908        request_id = request_id,
909    )
910}
911
912pub fn get_access_key_info_response(account_id: &str, request_id: &str) -> String {
913    format!(
914        r#"<?xml version="1.0" encoding="UTF-8"?>
915<GetAccessKeyInfoResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
916  <GetAccessKeyInfoResult>
917    <Account>{account_id}</Account>
918  </GetAccessKeyInfoResult>
919  <ResponseMetadata>
920    <RequestId>{request_id}</RequestId>
921  </ResponseMetadata>
922</GetAccessKeyInfoResponse>"#,
923        account_id = account_id,
924        request_id = request_id,
925    )
926}
927
928/// Generate an FSIA-prefixed temporary access key ID (20 chars total).
929pub fn generate_access_key_id() -> String {
930    let id = generate_alphanum_id(16);
931    format!("FSIA{}", id)
932}
933
934/// Generate a 40-character secret access key.
935pub fn generate_secret_access_key() -> String {
936    generate_alphanum_id(40)
937}
938
939/// Generate an AROA-prefixed role ID (21 chars total).
940pub fn generate_role_id() -> String {
941    let id = generate_alphanum_id(17);
942    format!("AROA{}", id)
943}
944
945/// Generate a session token that is exactly 356 characters starting with "FQoGZXIvYXdzE".
946pub fn generate_session_token() -> String {
947    // AWS session tokens are typically 356 chars and start with "FQoGZXIvYXdzE"
948    let prefix = "FQoGZXIvYXdzE";
949    let remaining = 356 - prefix.len(); // 343 chars needed
950                                        // Generate enough random bytes: we need at least ceil(343*3/4) = 258 bytes
951                                        // 18 UUIDs * 16 bytes = 288 bytes -> base64 = 384 chars (plenty)
952    let mut raw = Vec::with_capacity(288);
953    for _ in 0..18 {
954        raw.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
955    }
956    let encoded = base64::engine::general_purpose::STANDARD.encode(&raw);
957    // Take exactly what we need from the encoded data
958    let suffix = &encoded[..remaining];
959    format!("{}{}", prefix, suffix)
960}
961
962/// Generate alphanumeric ID of given length.
963fn generate_alphanum_id(len: usize) -> String {
964    let raw = format!(
965        "{}{}{}",
966        uuid::Uuid::new_v4(),
967        uuid::Uuid::new_v4(),
968        uuid::Uuid::new_v4(),
969    );
970    raw.replace('-', "")
971        .chars()
972        .filter(|c| c.is_alphanumeric())
973        .take(len)
974        .collect()
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    #[test]
982    fn url_encode_policy_escapes_special_chars() {
983        let encoded = url_encode_policy("a b");
984        assert!(encoded.contains("%20") || encoded.contains("+"));
985    }
986
987    #[test]
988    fn generate_alphanum_id_length() {
989        let id = generate_alphanum_id(20);
990        assert_eq!(id.len(), 20);
991        assert!(id.chars().all(|c| c.is_alphanumeric()));
992    }
993
994    #[test]
995    fn generate_alphanum_id_uniqueness() {
996        let a = generate_alphanum_id(16);
997        let b = generate_alphanum_id(16);
998        assert_ne!(a, b);
999    }
1000}