Skip to main content

fakecloud_iam/
state.rs

1use chrono::{DateTime, Utc};
2use parking_lot::RwLock;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6use fakecloud_core::multi_account::{AccountState, MultiAccountState};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct IamUser {
10    pub user_name: String,
11    pub user_id: String,
12    pub arn: String,
13    pub path: String,
14    pub created_at: DateTime<Utc>,
15    pub tags: Vec<Tag>,
16    pub permissions_boundary: Option<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct IamAccessKey {
21    pub access_key_id: String,
22    pub secret_access_key: String,
23    pub user_name: String,
24    pub status: String,
25    pub created_at: DateTime<Utc>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct IamRole {
30    pub role_name: String,
31    pub role_id: String,
32    pub arn: String,
33    pub path: String,
34    pub assume_role_policy_document: String,
35    pub created_at: DateTime<Utc>,
36    pub description: Option<String>,
37    pub max_session_duration: i32,
38    pub tags: Vec<Tag>,
39    pub permissions_boundary: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct IamPolicy {
44    pub policy_name: String,
45    pub policy_id: String,
46    pub arn: String,
47    pub path: String,
48    pub description: String,
49    pub created_at: DateTime<Utc>,
50    pub tags: Vec<Tag>,
51    pub default_version_id: String,
52    pub versions: Vec<PolicyVersion>,
53    pub next_version_num: u32,
54    pub attachment_count: u32,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PolicyVersion {
59    pub version_id: String,
60    pub document: String,
61    pub is_default: bool,
62    pub created_at: DateTime<Utc>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IamGroup {
67    pub group_name: String,
68    pub group_id: String,
69    pub arn: String,
70    pub path: String,
71    pub created_at: DateTime<Utc>,
72    pub members: Vec<String>,                      // user names
73    pub inline_policies: BTreeMap<String, String>, // policy_name -> document
74    pub attached_policies: Vec<String>,            // policy ARNs
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct IamInstanceProfile {
79    pub instance_profile_name: String,
80    pub instance_profile_id: String,
81    pub arn: String,
82    pub path: String,
83    pub created_at: DateTime<Utc>,
84    pub roles: Vec<String>, // role names
85    pub tags: Vec<Tag>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Tag {
90    pub key: String,
91    pub value: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct LoginProfile {
96    pub user_name: String,
97    pub created_at: DateTime<Utc>,
98    pub password_reset_required: bool,
99    /// Console password. Stored in plaintext for emulator parity —
100    /// fakecloud is not a security boundary, and round-tripping it
101    /// is what `ChangePassword` / `UpdateLoginProfile` need to
102    /// validate against. Empty for legacy snapshots that pre-date
103    /// password storage.
104    #[serde(default)]
105    pub password: String,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SamlProvider {
110    pub arn: String,
111    pub name: String,
112    pub saml_metadata_document: String,
113    pub created_at: DateTime<Utc>,
114    pub valid_until: DateTime<Utc>,
115    pub tags: Vec<Tag>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct OidcProvider {
120    pub arn: String,
121    pub url: String,
122    pub client_id_list: Vec<String>,
123    pub thumbprint_list: Vec<String>,
124    pub created_at: DateTime<Utc>,
125    pub tags: Vec<Tag>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ServerCertificate {
130    pub server_certificate_name: String,
131    pub server_certificate_id: String,
132    pub arn: String,
133    pub path: String,
134    pub certificate_body: String,
135    pub certificate_chain: Option<String>,
136    pub upload_date: DateTime<Utc>,
137    pub expiration: DateTime<Utc>,
138    pub tags: Vec<Tag>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct SigningCertificate {
143    pub certificate_id: String,
144    pub user_name: String,
145    pub certificate_body: String,
146    pub status: String,
147    pub upload_date: DateTime<Utc>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AccountPasswordPolicy {
152    pub minimum_password_length: u32,
153    pub require_symbols: bool,
154    pub require_numbers: bool,
155    pub require_uppercase_characters: bool,
156    pub require_lowercase_characters: bool,
157    pub allow_users_to_change_password: bool,
158    pub max_password_age: u32,
159    pub password_reuse_prevention: u32,
160    pub hard_expiry: bool,
161}
162
163impl Default for AccountPasswordPolicy {
164    fn default() -> Self {
165        Self {
166            minimum_password_length: 6,
167            require_symbols: false,
168            require_numbers: false,
169            require_uppercase_characters: false,
170            require_lowercase_characters: false,
171            allow_users_to_change_password: false,
172            max_password_age: 0,
173            password_reuse_prevention: 0,
174            hard_expiry: false,
175        }
176    }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct VirtualMfaDevice {
181    pub serial_number: String,
182    pub base32_string_seed: String,
183    pub qr_code_png: String,
184    pub enable_date: Option<DateTime<Utc>>,
185    pub user: Option<String>,
186    pub tags: Vec<Tag>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ServiceLinkedRoleDeletion {
191    pub deletion_task_id: String,
192    pub status: String,
193}
194
195/// Identity associated with a set of credentials, for GetCallerIdentity resolution.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct CredentialIdentity {
198    pub arn: String,
199    pub user_id: String,
200    pub account_id: String,
201}
202
203/// A temporary credential issued by STS (`AssumeRole`, `AssumeRoleWithWebIdentity`,
204/// `AssumeRoleWithSAML`, `GetSessionToken`, `GetFederationToken`).
205///
206/// Unlike [`CredentialIdentity`], which only remembers the principal ARN for
207/// `GetCallerIdentity`, this struct also retains the secret access key and
208/// session token so that SigV4 verification and IAM enforcement (added in
209/// later batches) can look them up when a client signs a request with
210/// temporary credentials. `expiration` is the absolute wall-clock time at
211/// which the credential becomes invalid.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct StsTempCredential {
214    pub access_key_id: String,
215    pub secret_access_key: String,
216    pub session_token: String,
217    pub principal_arn: String,
218    pub user_id: String,
219    pub account_id: String,
220    pub expiration: DateTime<Utc>,
221    /// Session policies passed to the STS call that minted this credential.
222    /// Raw JSON policy documents. The `Policy` parameter contributes one
223    /// entry; `PolicyArns` contribute additional entries (resolved to
224    /// documents at mint time). Empty when the STS call carried no
225    /// session policies.
226    #[serde(default)]
227    pub session_policies: Vec<String>,
228    /// True iff the AssumeRole / GetSessionToken call that minted this
229    /// credential supplied MFA (`SerialNumber` + `TokenCode`). Surfaces
230    /// to downstream IAM evaluation as `aws:MultiFactorAuthPresent`.
231    #[serde(default)]
232    pub mfa_present: bool,
233    /// Wall-clock time at which the credential was issued. Surfaces to
234    /// downstream IAM evaluation as `aws:TokenIssueTime` and feeds
235    /// `aws:MultiFactorAuthAge` (computed at evaluation time as
236    /// `now - issued_at` in seconds when MFA was asserted).
237    #[serde(default = "default_issued_at")]
238    pub issued_at: DateTime<Utc>,
239    /// `aws:FederatedProvider` — for AssumeRoleWithSAML this is the
240    /// SAML provider ARN; for AssumeRoleWithWebIdentity it is the OIDC
241    /// provider ARN (or a friendly host name like
242    /// `cognito-identity.amazonaws.com`); `None` for plain AssumeRole /
243    /// GetSessionToken / GetFederationToken.
244    #[serde(default)]
245    pub federated_provider: Option<String>,
246}
247
248/// Default for [`StsTempCredential::issued_at`] when deserializing
249/// snapshots written before the field existed.
250fn default_issued_at() -> DateTime<Utc> {
251    Utc::now()
252}
253
254/// Result of looking up a set of credentials by access key ID.
255///
256/// Carries the secret + resolved principal + owning account id. The account
257/// id is intentionally read from the credential itself rather than from
258/// global config, so that once #381 (multi-account isolation) lands, the same
259/// lookup already returns the correct account for the credential.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct SecretLookup {
262    pub secret_access_key: String,
263    pub session_token: Option<String>,
264    pub principal_arn: String,
265    pub user_id: String,
266    pub account_id: String,
267    /// Session policies from the STS call that minted this credential.
268    /// Empty for IAM user access keys.
269    pub session_policies: Vec<String>,
270    /// Tags on the principal (IAM user or assumed role) for
271    /// `aws:PrincipalTag/<key>` condition evaluation.
272    pub principal_tags: Option<BTreeMap<String, String>>,
273    /// True when the underlying STS credential was minted with MFA;
274    /// surfaces as `aws:MultiFactorAuthPresent` to downstream IAM
275    /// evaluation. Always false for raw IAM user access keys.
276    pub mfa_present: bool,
277    /// Wall-clock time at which the underlying STS credential was
278    /// issued. Surfaces as `aws:TokenIssueTime` and drives
279    /// `aws:MultiFactorAuthAge`. `None` for raw IAM user access keys
280    /// (AWS does not expose `aws:TokenIssueTime` for long-lived
281    /// credentials).
282    pub token_issued_at: Option<DateTime<Utc>>,
283    /// `aws:FederatedProvider` for the underlying credential, when
284    /// applicable. Set by AssumeRoleWithSAML / AssumeRoleWithWebIdentity;
285    /// always `None` for IAM user keys, plain AssumeRole, GetSessionToken,
286    /// and GetFederationToken.
287    pub federated_provider: Option<String>,
288}
289
290/// Convert a `Vec<Tag>` to a `BTreeMap<String, String>`.
291/// Returns `None` when the input is empty (no tags to evaluate).
292pub fn tags_to_hashmap(tags: &[Tag]) -> Option<BTreeMap<String, String>> {
293    if tags.is_empty() {
294        return None;
295    }
296    Some(
297        tags.iter()
298            .map(|t| (t.key.clone(), t.value.clone()))
299            .collect(),
300    )
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct SshPublicKey {
305    pub ssh_public_key_id: String,
306    pub user_name: String,
307    pub ssh_public_key_body: String,
308    pub status: String,
309    pub upload_date: DateTime<Utc>,
310    pub fingerprint: String,
311}
312
313/// Tracks when an access key was last used.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct AccessKeyLastUsed {
316    pub last_used_date: DateTime<Utc>,
317    pub service_name: String,
318    pub region: String,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct IamState {
323    pub account_id: String,
324    pub users: BTreeMap<String, IamUser>,
325    pub access_keys: BTreeMap<String, Vec<IamAccessKey>>, // username -> keys
326    pub roles: BTreeMap<String, IamRole>,
327    pub policies: BTreeMap<String, IamPolicy>, // arn -> policy
328    pub role_policies: BTreeMap<String, Vec<String>>, // role_name -> managed policy arns
329    pub role_inline_policies: BTreeMap<String, BTreeMap<String, String>>, // role_name -> {policy_name -> doc}
330    pub user_policies: BTreeMap<String, Vec<String>>, // user_name -> managed policy arns
331    pub user_inline_policies: BTreeMap<String, BTreeMap<String, String>>, // user_name -> {policy_name -> doc}
332    pub groups: BTreeMap<String, IamGroup>,
333    pub instance_profiles: BTreeMap<String, IamInstanceProfile>,
334    pub login_profiles: BTreeMap<String, LoginProfile>,
335    pub saml_providers: BTreeMap<String, SamlProvider>, // arn -> provider
336    pub oidc_providers: BTreeMap<String, OidcProvider>, // arn -> provider
337    pub server_certificates: BTreeMap<String, ServerCertificate>, // name -> cert
338    pub signing_certificates: BTreeMap<String, Vec<SigningCertificate>>, // user_name -> certs
339    pub account_aliases: Vec<String>,
340    pub account_password_policy: Option<AccountPasswordPolicy>,
341    pub virtual_mfa_devices: BTreeMap<String, VirtualMfaDevice>, // serial_number -> device
342    pub service_linked_role_deletions: BTreeMap<String, ServiceLinkedRoleDeletion>,
343    /// Maps access key ID to the identity that should be returned by GetCallerIdentity.
344    pub credential_identities: BTreeMap<String, CredentialIdentity>,
345    /// Temporary credentials issued by STS, keyed by access key ID. Includes
346    /// the secret access key and session token — required for SigV4
347    /// verification and IAM enforcement. Expired entries are purged lazily on
348    /// lookup.
349    pub sts_temp_credentials: BTreeMap<String, StsTempCredential>,
350    pub credential_report_generated: bool,
351    pub ssh_public_keys: BTreeMap<String, Vec<SshPublicKey>>, // user_name -> keys
352    pub access_key_last_used: BTreeMap<String, AccessKeyLastUsed>,
353    /// Per-user service-specific credentials (Codecommit/Keyspaces).
354    #[serde(default)]
355    pub service_specific_credentials: BTreeMap<String, Vec<ServiceSpecificCredential>>, // user -> creds
356    /// Per-resource-arn tag map for SAML/Server cert/MFA device tags.
357    #[serde(default)]
358    pub extra_tags: BTreeMap<String, Vec<(String, String)>>,
359    /// Organizations integration toggles.
360    #[serde(default)]
361    pub organizations_root_credentials_management: bool,
362    #[serde(default)]
363    pub organizations_root_sessions: bool,
364    /// Generated ServiceLastAccessed jobs keyed by job id.
365    #[serde(default)]
366    pub service_last_accessed_jobs: BTreeMap<String, ServiceLastAccessedJob>,
367    /// Organizations access reports keyed by job id.
368    #[serde(default)]
369    pub organizations_access_reports: BTreeMap<String, OrganizationsAccessReport>,
370    /// `SetSecurityTokenServicePreferences` value (e.g. `v1Token`,
371    /// `v2Token`). `None` means caller hasn't configured a preference.
372    #[serde(default)]
373    pub global_endpoint_token_version: Option<String>,
374    /// Delegation requests keyed by id. Records every state transition
375    /// (`PENDING` -> `ACCEPTED`/`REJECTED`/`SENT`) and the parameters
376    /// supplied at create-time so `GetDelegationRequest` can roundtrip
377    /// them.
378    #[serde(default)]
379    pub delegation_requests: BTreeMap<String, DelegationRequest>,
380    /// Whether outbound web identity federation is enabled for this
381    /// account. Toggled by `EnableOutboundWebIdentityFederation` /
382    /// `DisableOutboundWebIdentityFederation`.
383    #[serde(default)]
384    pub outbound_web_identity_federation_enabled: bool,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct DelegationRequest {
389    pub id: String,
390    pub owner_account_id: Option<String>,
391    pub description: String,
392    pub request_message: Option<String>,
393    pub requestor_workflow_id: String,
394    pub redirect_url: Option<String>,
395    pub notification_channel: String,
396    pub session_duration: i64,
397    pub only_send_by_owner: bool,
398    pub status: String,
399    pub notes: Option<String>,
400    pub created_at: DateTime<Utc>,
401    pub policy_template_arn: Option<String>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct ServiceSpecificCredential {
406    pub credential_id: String,
407    pub user_name: String,
408    pub service_name: String,
409    pub service_user_name: String,
410    pub service_password: String,
411    pub status: String,
412    pub create_date: DateTime<Utc>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct ServiceLastAccessedJob {
417    pub job_id: String,
418    pub status: String,
419    pub job_creation_date: DateTime<Utc>,
420    pub arn: String,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct OrganizationsAccessReport {
425    pub job_id: String,
426    pub status: String,
427    pub created_at: DateTime<Utc>,
428    pub entity_path: String,
429}
430
431impl IamState {
432    pub fn new(account_id: &str) -> Self {
433        let mut state = Self {
434            account_id: account_id.to_string(),
435            users: BTreeMap::new(),
436            access_keys: BTreeMap::new(),
437            roles: BTreeMap::new(),
438            policies: BTreeMap::new(),
439            role_policies: BTreeMap::new(),
440            role_inline_policies: BTreeMap::new(),
441            user_policies: BTreeMap::new(),
442            user_inline_policies: BTreeMap::new(),
443            groups: BTreeMap::new(),
444            instance_profiles: BTreeMap::new(),
445            login_profiles: BTreeMap::new(),
446            saml_providers: BTreeMap::new(),
447            oidc_providers: BTreeMap::new(),
448            server_certificates: BTreeMap::new(),
449            signing_certificates: BTreeMap::new(),
450            account_aliases: Vec::new(),
451            account_password_policy: None,
452            virtual_mfa_devices: BTreeMap::new(),
453            service_linked_role_deletions: BTreeMap::new(),
454            credential_identities: BTreeMap::new(),
455            sts_temp_credentials: BTreeMap::new(),
456            credential_report_generated: false,
457            ssh_public_keys: BTreeMap::new(),
458            access_key_last_used: BTreeMap::new(),
459            service_specific_credentials: BTreeMap::new(),
460            extra_tags: BTreeMap::new(),
461            organizations_root_credentials_management: false,
462            organizations_root_sessions: false,
463            service_last_accessed_jobs: BTreeMap::new(),
464            organizations_access_reports: BTreeMap::new(),
465            global_endpoint_token_version: None,
466            delegation_requests: BTreeMap::new(),
467            outbound_web_identity_federation_enabled: false,
468        };
469        state.seed_default_service_linked_roles();
470        state
471    }
472
473    /// AWS accounts ship with a handful of service-linked roles created
474    /// automatically (Support, Trusted Advisor, ...). `aws_iam_roles` and other
475    /// callers expect a non-empty `ListRoles` on a fresh account, so seed them.
476    fn seed_default_service_linked_roles(&mut self) {
477        let now = Utc::now();
478        for (service, name) in [
479            ("support.amazonaws.com", "AWSServiceRoleForSupport"),
480            (
481                "trustedadvisor.amazonaws.com",
482                "AWSServiceRoleForTrustedAdvisor",
483            ),
484        ] {
485            let path = format!("/aws-service-role/{service}/");
486            let arn = format!("arn:aws:iam::{}:role{}{}", self.account_id, path, name);
487            self.roles.insert(
488                name.to_string(),
489                IamRole {
490                    role_name: name.to_string(),
491                    role_id: format!("AROA{:0>17}", name.len()),
492                    arn,
493                    path,
494                    assume_role_policy_document: format!(
495                        "{{\"Version\":\"2012-10-17\",\"Statement\":[{{\"Effect\":\"Allow\",\"Principal\":{{\"Service\":\"{service}\"}},\"Action\":\"sts:AssumeRole\"}}]}}"
496                    ),
497                    created_at: now,
498                    description: None,
499                    max_session_duration: 3600,
500                    tags: Vec::new(),
501                    permissions_boundary: None,
502                },
503            );
504        }
505    }
506
507    pub fn reset(&mut self) {
508        let account_id = self.account_id.clone();
509        *self = Self::new(&account_id);
510    }
511
512    /// Look up the secret access key, session token, and resolved principal
513    /// for a given access key ID.
514    ///
515    /// Searches IAM user access keys first, then STS temporary credentials.
516    /// Expired STS temporary credentials are purged in-place and skipped.
517    ///
518    /// Returns `None` if the AKID is unknown or its STS credential has
519    /// expired.
520    ///
521    /// Required for SigV4 signature verification (batch 3) and principal
522    /// resolution (batch 4). Callers must hold a write lock on
523    /// [`IamState`] to allow the lazy purge; read-only callers should use
524    /// [`IamState::credential_secret_readonly`].
525    pub fn credential_secret(&mut self, access_key_id: &str) -> Option<SecretLookup> {
526        // IAM user access keys: look up by scanning (same pattern the
527        // existing GetCallerIdentity path uses).
528        for keys in self.access_keys.values() {
529            for key in keys {
530                // Deactivated (Inactive) keys must not authenticate; AWS
531                // returns InvalidClientTokenId. Skipping makes the lookup miss
532                // (bug-audit 2026-05-28, 5.1).
533                if key.access_key_id == access_key_id && key.status == "Active" {
534                    if let Some(user) = self.users.get(&key.user_name) {
535                        return Some(SecretLookup {
536                            secret_access_key: key.secret_access_key.clone(),
537                            session_token: None,
538                            principal_arn: user.arn.clone(),
539                            user_id: user.user_id.clone(),
540                            account_id: self.account_id.clone(),
541                            session_policies: Vec::new(),
542                            principal_tags: tags_to_hashmap(&user.tags),
543                            mfa_present: false,
544                            token_issued_at: None,
545                            federated_provider: None,
546                        });
547                    }
548                }
549            }
550        }
551
552        // STS temporary credentials: direct hash lookup, with lazy expiry
553        // purging so expired entries don't accumulate.
554        let now = Utc::now();
555        if let Some(temp) = self.sts_temp_credentials.get(access_key_id) {
556            if temp.expiration > now {
557                let principal_tags = self.resolve_role_tags(&temp.principal_arn);
558                return Some(SecretLookup {
559                    secret_access_key: temp.secret_access_key.clone(),
560                    session_token: Some(temp.session_token.clone()),
561                    principal_arn: temp.principal_arn.clone(),
562                    user_id: temp.user_id.clone(),
563                    account_id: temp.account_id.clone(),
564                    session_policies: temp.session_policies.clone(),
565                    principal_tags,
566                    mfa_present: temp.mfa_present,
567                    token_issued_at: Some(temp.issued_at),
568                    federated_provider: temp.federated_provider.clone(),
569                });
570            }
571            self.sts_temp_credentials.remove(access_key_id);
572        }
573        None
574    }
575
576    /// Read-only variant of [`IamState::credential_secret`] that does not
577    /// purge expired entries. Prefer the mutable variant wherever possible
578    /// to keep the temp-credential table small.
579    pub fn credential_secret_readonly(&self, access_key_id: &str) -> Option<SecretLookup> {
580        for keys in self.access_keys.values() {
581            for key in keys {
582                // Deactivated (Inactive) keys must not authenticate; AWS
583                // returns InvalidClientTokenId. Skipping makes the lookup miss
584                // (bug-audit 2026-05-28, 5.1).
585                if key.access_key_id == access_key_id && key.status == "Active" {
586                    if let Some(user) = self.users.get(&key.user_name) {
587                        return Some(SecretLookup {
588                            secret_access_key: key.secret_access_key.clone(),
589                            session_token: None,
590                            principal_arn: user.arn.clone(),
591                            user_id: user.user_id.clone(),
592                            account_id: self.account_id.clone(),
593                            session_policies: Vec::new(),
594                            principal_tags: tags_to_hashmap(&user.tags),
595                            mfa_present: false,
596                            token_issued_at: None,
597                            federated_provider: None,
598                        });
599                    }
600                }
601            }
602        }
603
604        let now = Utc::now();
605        let temp = self.sts_temp_credentials.get(access_key_id)?;
606        if temp.expiration <= now {
607            return None;
608        }
609        let principal_tags = self.resolve_role_tags(&temp.principal_arn);
610        Some(SecretLookup {
611            secret_access_key: temp.secret_access_key.clone(),
612            session_token: Some(temp.session_token.clone()),
613            principal_arn: temp.principal_arn.clone(),
614            user_id: temp.user_id.clone(),
615            account_id: temp.account_id.clone(),
616            session_policies: temp.session_policies.clone(),
617            principal_tags,
618            mfa_present: temp.mfa_present,
619            token_issued_at: Some(temp.issued_at),
620            federated_provider: temp.federated_provider.clone(),
621        })
622    }
623
624    /// Resolve role tags from an assumed-role principal ARN.
625    /// ARN format: `arn:aws:sts::<account>:assumed-role/<role-name>/<session>`
626    /// Looks up the role by name and returns its tags.
627    fn resolve_role_tags(&self, principal_arn: &str) -> Option<BTreeMap<String, String>> {
628        // assumed-role ARNs: arn:aws:sts::<account>:assumed-role/<role>/<session>
629        let parts: Vec<&str> = principal_arn.split(':').collect();
630        if parts.len() < 6 {
631            return None;
632        }
633        let resource = parts[5];
634        if let Some(rest) = resource.strip_prefix("assumed-role/") {
635            let role_name = rest.split('/').next()?;
636            let role = self.roles.get(role_name)?;
637            return tags_to_hashmap(&role.tags);
638        }
639        None
640    }
641}
642
643impl AccountState for IamState {
644    fn new_for_account(account_id: &str, _region: &str, _endpoint: &str) -> Self {
645        Self::new(account_id)
646    }
647}
648
649pub type SharedIamState = std::sync::Arc<RwLock<MultiAccountState<IamState>>>;
650
651/// On-disk snapshot envelope for IAM state. Versioned so future schema
652/// changes fail loudly instead of silently corrupting state.
653///
654/// Schema v2 stores multi-account state. v1 snapshots are migrated on
655/// load by wrapping the single `IamState` as the default account.
656#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct IamSnapshot {
658    pub schema_version: u32,
659    /// v2+: multi-account state. Present when `schema_version >= 2`.
660    #[serde(default)]
661    pub accounts: Option<MultiAccountState<IamState>>,
662    /// v1 compat: single-account state. Present when `schema_version == 1`.
663    #[serde(default)]
664    pub state: Option<IamState>,
665}
666
667pub const IAM_SNAPSHOT_SCHEMA_VERSION: u32 = 2;
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use fakecloud_aws::arn::Arn;
673
674    fn iam_user(name: &str, account_id: &str) -> IamUser {
675        IamUser {
676            user_name: name.to_string(),
677            user_id: format!("AIDA{}", name.to_uppercase()),
678            arn: Arn::global("iam", account_id, &format!("user/{name}")).to_string(),
679            path: "/".to_string(),
680            created_at: Utc::now(),
681            tags: Vec::new(),
682            permissions_boundary: None,
683        }
684    }
685
686    fn iam_key(user: &str, akid: &str, secret: &str) -> IamAccessKey {
687        IamAccessKey {
688            access_key_id: akid.to_string(),
689            secret_access_key: secret.to_string(),
690            user_name: user.to_string(),
691            status: "Active".to_string(),
692            created_at: Utc::now(),
693        }
694    }
695
696    #[test]
697    fn credential_secret_returns_iam_user_key() {
698        let mut state = IamState::new("123456789012");
699        state
700            .users
701            .insert("alice".to_string(), iam_user("alice", "123456789012"));
702        state.access_keys.insert(
703            "alice".to_string(),
704            vec![iam_key("alice", "FKIAALICE", "secret-alice")],
705        );
706        let lookup = state.credential_secret("FKIAALICE").unwrap();
707        assert_eq!(lookup.secret_access_key, "secret-alice");
708        assert_eq!(lookup.principal_arn, "arn:aws:iam::123456789012:user/alice");
709        assert_eq!(lookup.account_id, "123456789012");
710        assert_eq!(lookup.session_token, None);
711    }
712
713    #[test]
714    fn credential_secret_returns_sts_temp_credential_when_unexpired() {
715        let mut state = IamState::new("123456789012");
716        state.sts_temp_credentials.insert(
717            "FSIATEMPKEY".to_string(),
718            StsTempCredential {
719                access_key_id: "FSIATEMPKEY".to_string(),
720                secret_access_key: "temp-secret".to_string(),
721                session_token: "temp-token".to_string(),
722                principal_arn: "arn:aws:sts::123456789012:assumed-role/R/s".to_string(),
723                user_id: "AROA:session".to_string(),
724                account_id: "123456789012".to_string(),
725                expiration: Utc::now() + chrono::Duration::minutes(30),
726                session_policies: Vec::new(),
727                mfa_present: false,
728                issued_at: Utc::now(),
729                federated_provider: None,
730            },
731        );
732        let lookup = state.credential_secret("FSIATEMPKEY").unwrap();
733        assert_eq!(lookup.secret_access_key, "temp-secret");
734        assert_eq!(lookup.session_token.as_deref(), Some("temp-token"));
735        assert_eq!(
736            lookup.principal_arn,
737            "arn:aws:sts::123456789012:assumed-role/R/s"
738        );
739    }
740
741    #[test]
742    fn credential_secret_purges_expired_sts_credentials() {
743        let mut state = IamState::new("123456789012");
744        state.sts_temp_credentials.insert(
745            "FSIAOLD".to_string(),
746            StsTempCredential {
747                access_key_id: "FSIAOLD".to_string(),
748                secret_access_key: "s".to_string(),
749                session_token: "t".to_string(),
750                principal_arn: "arn".to_string(),
751                user_id: "id".to_string(),
752                account_id: "123456789012".to_string(),
753                expiration: Utc::now() - chrono::Duration::seconds(1),
754                session_policies: Vec::new(),
755                mfa_present: false,
756                issued_at: Utc::now(),
757                federated_provider: None,
758            },
759        );
760        assert!(state.credential_secret("FSIAOLD").is_none());
761        assert!(!state.sts_temp_credentials.contains_key("FSIAOLD"));
762    }
763
764    #[test]
765    fn credential_secret_readonly_does_not_purge() {
766        let mut state = IamState::new("123456789012");
767        state.sts_temp_credentials.insert(
768            "FSIAOLD".to_string(),
769            StsTempCredential {
770                access_key_id: "FSIAOLD".to_string(),
771                secret_access_key: "s".to_string(),
772                session_token: "t".to_string(),
773                principal_arn: "arn".to_string(),
774                user_id: "id".to_string(),
775                account_id: "123456789012".to_string(),
776                expiration: Utc::now() - chrono::Duration::seconds(1),
777                session_policies: Vec::new(),
778                mfa_present: false,
779                issued_at: Utc::now(),
780                federated_provider: None,
781            },
782        );
783        assert!(state.credential_secret_readonly("FSIAOLD").is_none());
784        assert!(state.sts_temp_credentials.contains_key("FSIAOLD"));
785    }
786
787    #[test]
788    fn credential_secret_returns_none_for_unknown_akid() {
789        let mut state = IamState::new("123456789012");
790        assert!(state.credential_secret("FKIAUNKNOWN").is_none());
791    }
792
793    // bug-audit 2026-05-28, 5.1: a deactivated (Inactive) access key must not
794    // authenticate, so the credential lookup must miss.
795    #[test]
796    fn credential_secret_skips_inactive_key() {
797        let mut state = IamState::new("123456789012");
798        let mut key = iam_key("alice", "FKIAALICE", "secret123");
799        key.status = "Inactive".to_string();
800        state.access_keys.insert("alice".to_string(), vec![key]);
801        assert!(state.credential_secret("FKIAALICE").is_none());
802    }
803}