Skip to main content

fakecloud_iam/
state.rs

1use chrono::{DateTime, Utc};
2use parking_lot::RwLock;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
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: HashMap<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}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SamlProvider {
103    pub arn: String,
104    pub name: String,
105    pub saml_metadata_document: String,
106    pub created_at: DateTime<Utc>,
107    pub valid_until: DateTime<Utc>,
108    pub tags: Vec<Tag>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct OidcProvider {
113    pub arn: String,
114    pub url: String,
115    pub client_id_list: Vec<String>,
116    pub thumbprint_list: Vec<String>,
117    pub created_at: DateTime<Utc>,
118    pub tags: Vec<Tag>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ServerCertificate {
123    pub server_certificate_name: String,
124    pub server_certificate_id: String,
125    pub arn: String,
126    pub path: String,
127    pub certificate_body: String,
128    pub certificate_chain: Option<String>,
129    pub upload_date: DateTime<Utc>,
130    pub expiration: DateTime<Utc>,
131    pub tags: Vec<Tag>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SigningCertificate {
136    pub certificate_id: String,
137    pub user_name: String,
138    pub certificate_body: String,
139    pub status: String,
140    pub upload_date: DateTime<Utc>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct AccountPasswordPolicy {
145    pub minimum_password_length: u32,
146    pub require_symbols: bool,
147    pub require_numbers: bool,
148    pub require_uppercase_characters: bool,
149    pub require_lowercase_characters: bool,
150    pub allow_users_to_change_password: bool,
151    pub max_password_age: u32,
152    pub password_reuse_prevention: u32,
153    pub hard_expiry: bool,
154}
155
156impl Default for AccountPasswordPolicy {
157    fn default() -> Self {
158        Self {
159            minimum_password_length: 6,
160            require_symbols: false,
161            require_numbers: false,
162            require_uppercase_characters: false,
163            require_lowercase_characters: false,
164            allow_users_to_change_password: false,
165            max_password_age: 0,
166            password_reuse_prevention: 0,
167            hard_expiry: false,
168        }
169    }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct VirtualMfaDevice {
174    pub serial_number: String,
175    pub base32_string_seed: String,
176    pub qr_code_png: String,
177    pub enable_date: Option<DateTime<Utc>>,
178    pub user: Option<String>,
179    pub tags: Vec<Tag>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ServiceLinkedRoleDeletion {
184    pub deletion_task_id: String,
185    pub status: String,
186}
187
188/// Identity associated with a set of credentials, for GetCallerIdentity resolution.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct CredentialIdentity {
191    pub arn: String,
192    pub user_id: String,
193    pub account_id: String,
194}
195
196/// A temporary credential issued by STS (`AssumeRole`, `AssumeRoleWithWebIdentity`,
197/// `AssumeRoleWithSAML`, `GetSessionToken`, `GetFederationToken`).
198///
199/// Unlike [`CredentialIdentity`], which only remembers the principal ARN for
200/// `GetCallerIdentity`, this struct also retains the secret access key and
201/// session token so that SigV4 verification and IAM enforcement (added in
202/// later batches) can look them up when a client signs a request with
203/// temporary credentials. `expiration` is the absolute wall-clock time at
204/// which the credential becomes invalid.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct StsTempCredential {
207    pub access_key_id: String,
208    pub secret_access_key: String,
209    pub session_token: String,
210    pub principal_arn: String,
211    pub user_id: String,
212    pub account_id: String,
213    pub expiration: DateTime<Utc>,
214    /// Session policies passed to the STS call that minted this credential.
215    /// Raw JSON policy documents. The `Policy` parameter contributes one
216    /// entry; `PolicyArns` contribute additional entries (resolved to
217    /// documents at mint time). Empty when the STS call carried no
218    /// session policies.
219    #[serde(default)]
220    pub session_policies: Vec<String>,
221}
222
223/// Result of looking up a set of credentials by access key ID.
224///
225/// Carries the secret + resolved principal + owning account id. The account
226/// id is intentionally read from the credential itself rather than from
227/// global config, so that once #381 (multi-account isolation) lands, the same
228/// lookup already returns the correct account for the credential.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct SecretLookup {
231    pub secret_access_key: String,
232    pub session_token: Option<String>,
233    pub principal_arn: String,
234    pub user_id: String,
235    pub account_id: String,
236    /// Session policies from the STS call that minted this credential.
237    /// Empty for IAM user access keys.
238    pub session_policies: Vec<String>,
239    /// Tags on the principal (IAM user or assumed role) for
240    /// `aws:PrincipalTag/<key>` condition evaluation.
241    pub principal_tags: Option<HashMap<String, String>>,
242}
243
244/// Convert a `Vec<Tag>` to a `HashMap<String, String>`.
245/// Returns `None` when the input is empty (no tags to evaluate).
246pub fn tags_to_hashmap(tags: &[Tag]) -> Option<HashMap<String, String>> {
247    if tags.is_empty() {
248        return None;
249    }
250    Some(
251        tags.iter()
252            .map(|t| (t.key.clone(), t.value.clone()))
253            .collect(),
254    )
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct SshPublicKey {
259    pub ssh_public_key_id: String,
260    pub user_name: String,
261    pub ssh_public_key_body: String,
262    pub status: String,
263    pub upload_date: DateTime<Utc>,
264    pub fingerprint: String,
265}
266
267/// Tracks when an access key was last used.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct AccessKeyLastUsed {
270    pub last_used_date: DateTime<Utc>,
271    pub service_name: String,
272    pub region: String,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct IamState {
277    pub account_id: String,
278    pub users: HashMap<String, IamUser>,
279    pub access_keys: HashMap<String, Vec<IamAccessKey>>, // username -> keys
280    pub roles: HashMap<String, IamRole>,
281    pub policies: HashMap<String, IamPolicy>, // arn -> policy
282    pub role_policies: HashMap<String, Vec<String>>, // role_name -> managed policy arns
283    pub role_inline_policies: HashMap<String, HashMap<String, String>>, // role_name -> {policy_name -> doc}
284    pub user_policies: HashMap<String, Vec<String>>, // user_name -> managed policy arns
285    pub user_inline_policies: HashMap<String, HashMap<String, String>>, // user_name -> {policy_name -> doc}
286    pub groups: HashMap<String, IamGroup>,
287    pub instance_profiles: HashMap<String, IamInstanceProfile>,
288    pub login_profiles: HashMap<String, LoginProfile>,
289    pub saml_providers: HashMap<String, SamlProvider>, // arn -> provider
290    pub oidc_providers: HashMap<String, OidcProvider>, // arn -> provider
291    pub server_certificates: HashMap<String, ServerCertificate>, // name -> cert
292    pub signing_certificates: HashMap<String, Vec<SigningCertificate>>, // user_name -> certs
293    pub account_aliases: Vec<String>,
294    pub account_password_policy: Option<AccountPasswordPolicy>,
295    pub virtual_mfa_devices: HashMap<String, VirtualMfaDevice>, // serial_number -> device
296    pub service_linked_role_deletions: HashMap<String, ServiceLinkedRoleDeletion>,
297    /// Maps access key ID to the identity that should be returned by GetCallerIdentity.
298    pub credential_identities: HashMap<String, CredentialIdentity>,
299    /// Temporary credentials issued by STS, keyed by access key ID. Includes
300    /// the secret access key and session token — required for SigV4
301    /// verification and IAM enforcement. Expired entries are purged lazily on
302    /// lookup.
303    pub sts_temp_credentials: HashMap<String, StsTempCredential>,
304    pub credential_report_generated: bool,
305    pub ssh_public_keys: HashMap<String, Vec<SshPublicKey>>, // user_name -> keys
306    pub access_key_last_used: HashMap<String, AccessKeyLastUsed>,
307}
308
309impl IamState {
310    pub fn new(account_id: &str) -> Self {
311        Self {
312            account_id: account_id.to_string(),
313            users: HashMap::new(),
314            access_keys: HashMap::new(),
315            roles: HashMap::new(),
316            policies: HashMap::new(),
317            role_policies: HashMap::new(),
318            role_inline_policies: HashMap::new(),
319            user_policies: HashMap::new(),
320            user_inline_policies: HashMap::new(),
321            groups: HashMap::new(),
322            instance_profiles: HashMap::new(),
323            login_profiles: HashMap::new(),
324            saml_providers: HashMap::new(),
325            oidc_providers: HashMap::new(),
326            server_certificates: HashMap::new(),
327            signing_certificates: HashMap::new(),
328            account_aliases: Vec::new(),
329            account_password_policy: None,
330            virtual_mfa_devices: HashMap::new(),
331            service_linked_role_deletions: HashMap::new(),
332            credential_identities: HashMap::new(),
333            sts_temp_credentials: HashMap::new(),
334            credential_report_generated: false,
335            ssh_public_keys: HashMap::new(),
336            access_key_last_used: HashMap::new(),
337        }
338    }
339
340    pub fn reset(&mut self) {
341        let account_id = self.account_id.clone();
342        *self = Self::new(&account_id);
343    }
344
345    /// Look up the secret access key, session token, and resolved principal
346    /// for a given access key ID.
347    ///
348    /// Searches IAM user access keys first, then STS temporary credentials.
349    /// Expired STS temporary credentials are purged in-place and skipped.
350    ///
351    /// Returns `None` if the AKID is unknown or its STS credential has
352    /// expired.
353    ///
354    /// Required for SigV4 signature verification (batch 3) and principal
355    /// resolution (batch 4). Callers must hold a write lock on
356    /// [`IamState`] to allow the lazy purge; read-only callers should use
357    /// [`IamState::credential_secret_readonly`].
358    pub fn credential_secret(&mut self, access_key_id: &str) -> Option<SecretLookup> {
359        // IAM user access keys: look up by scanning (same pattern the
360        // existing GetCallerIdentity path uses).
361        for keys in self.access_keys.values() {
362            for key in keys {
363                if key.access_key_id == access_key_id {
364                    if let Some(user) = self.users.get(&key.user_name) {
365                        return Some(SecretLookup {
366                            secret_access_key: key.secret_access_key.clone(),
367                            session_token: None,
368                            principal_arn: user.arn.clone(),
369                            user_id: user.user_id.clone(),
370                            account_id: self.account_id.clone(),
371                            session_policies: Vec::new(),
372                            principal_tags: tags_to_hashmap(&user.tags),
373                        });
374                    }
375                }
376            }
377        }
378
379        // STS temporary credentials: direct hash lookup, with lazy expiry
380        // purging so expired entries don't accumulate.
381        let now = Utc::now();
382        if let Some(temp) = self.sts_temp_credentials.get(access_key_id) {
383            if temp.expiration > now {
384                let principal_tags = self.resolve_role_tags(&temp.principal_arn);
385                return Some(SecretLookup {
386                    secret_access_key: temp.secret_access_key.clone(),
387                    session_token: Some(temp.session_token.clone()),
388                    principal_arn: temp.principal_arn.clone(),
389                    user_id: temp.user_id.clone(),
390                    account_id: temp.account_id.clone(),
391                    session_policies: temp.session_policies.clone(),
392                    principal_tags,
393                });
394            }
395            self.sts_temp_credentials.remove(access_key_id);
396        }
397        None
398    }
399
400    /// Read-only variant of [`IamState::credential_secret`] that does not
401    /// purge expired entries. Prefer the mutable variant wherever possible
402    /// to keep the temp-credential table small.
403    pub fn credential_secret_readonly(&self, access_key_id: &str) -> Option<SecretLookup> {
404        for keys in self.access_keys.values() {
405            for key in keys {
406                if key.access_key_id == access_key_id {
407                    if let Some(user) = self.users.get(&key.user_name) {
408                        return Some(SecretLookup {
409                            secret_access_key: key.secret_access_key.clone(),
410                            session_token: None,
411                            principal_arn: user.arn.clone(),
412                            user_id: user.user_id.clone(),
413                            account_id: self.account_id.clone(),
414                            session_policies: Vec::new(),
415                            principal_tags: tags_to_hashmap(&user.tags),
416                        });
417                    }
418                }
419            }
420        }
421
422        let now = Utc::now();
423        let temp = self.sts_temp_credentials.get(access_key_id)?;
424        if temp.expiration <= now {
425            return None;
426        }
427        let principal_tags = self.resolve_role_tags(&temp.principal_arn);
428        Some(SecretLookup {
429            secret_access_key: temp.secret_access_key.clone(),
430            session_token: Some(temp.session_token.clone()),
431            principal_arn: temp.principal_arn.clone(),
432            user_id: temp.user_id.clone(),
433            account_id: temp.account_id.clone(),
434            session_policies: temp.session_policies.clone(),
435            principal_tags,
436        })
437    }
438
439    /// Resolve role tags from an assumed-role principal ARN.
440    /// ARN format: `arn:aws:sts::<account>:assumed-role/<role-name>/<session>`
441    /// Looks up the role by name and returns its tags.
442    fn resolve_role_tags(&self, principal_arn: &str) -> Option<HashMap<String, String>> {
443        // assumed-role ARNs: arn:aws:sts::<account>:assumed-role/<role>/<session>
444        let parts: Vec<&str> = principal_arn.split(':').collect();
445        if parts.len() < 6 {
446            return None;
447        }
448        let resource = parts[5];
449        if let Some(rest) = resource.strip_prefix("assumed-role/") {
450            let role_name = rest.split('/').next()?;
451            let role = self.roles.get(role_name)?;
452            return tags_to_hashmap(&role.tags);
453        }
454        None
455    }
456}
457
458impl AccountState for IamState {
459    fn new_for_account(account_id: &str, _region: &str, _endpoint: &str) -> Self {
460        Self::new(account_id)
461    }
462}
463
464pub type SharedIamState = std::sync::Arc<RwLock<MultiAccountState<IamState>>>;
465
466/// On-disk snapshot envelope for IAM state. Versioned so future schema
467/// changes fail loudly instead of silently corrupting state.
468///
469/// Schema v2 stores multi-account state. v1 snapshots are migrated on
470/// load by wrapping the single `IamState` as the default account.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct IamSnapshot {
473    pub schema_version: u32,
474    /// v2+: multi-account state. Present when `schema_version >= 2`.
475    #[serde(default)]
476    pub accounts: Option<MultiAccountState<IamState>>,
477    /// v1 compat: single-account state. Present when `schema_version == 1`.
478    #[serde(default)]
479    pub state: Option<IamState>,
480}
481
482pub const IAM_SNAPSHOT_SCHEMA_VERSION: u32 = 2;
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn iam_user(name: &str, account_id: &str) -> IamUser {
489        IamUser {
490            user_name: name.to_string(),
491            user_id: format!("AIDA{}", name.to_uppercase()),
492            arn: format!("arn:aws:iam::{}:user/{}", account_id, name),
493            path: "/".to_string(),
494            created_at: Utc::now(),
495            tags: Vec::new(),
496            permissions_boundary: None,
497        }
498    }
499
500    fn iam_key(user: &str, akid: &str, secret: &str) -> IamAccessKey {
501        IamAccessKey {
502            access_key_id: akid.to_string(),
503            secret_access_key: secret.to_string(),
504            user_name: user.to_string(),
505            status: "Active".to_string(),
506            created_at: Utc::now(),
507        }
508    }
509
510    #[test]
511    fn credential_secret_returns_iam_user_key() {
512        let mut state = IamState::new("123456789012");
513        state
514            .users
515            .insert("alice".to_string(), iam_user("alice", "123456789012"));
516        state.access_keys.insert(
517            "alice".to_string(),
518            vec![iam_key("alice", "FKIAALICE", "secret-alice")],
519        );
520        let lookup = state.credential_secret("FKIAALICE").unwrap();
521        assert_eq!(lookup.secret_access_key, "secret-alice");
522        assert_eq!(lookup.principal_arn, "arn:aws:iam::123456789012:user/alice");
523        assert_eq!(lookup.account_id, "123456789012");
524        assert_eq!(lookup.session_token, None);
525    }
526
527    #[test]
528    fn credential_secret_returns_sts_temp_credential_when_unexpired() {
529        let mut state = IamState::new("123456789012");
530        state.sts_temp_credentials.insert(
531            "FSIATEMPKEY".to_string(),
532            StsTempCredential {
533                access_key_id: "FSIATEMPKEY".to_string(),
534                secret_access_key: "temp-secret".to_string(),
535                session_token: "temp-token".to_string(),
536                principal_arn: "arn:aws:sts::123456789012:assumed-role/R/s".to_string(),
537                user_id: "AROA:session".to_string(),
538                account_id: "123456789012".to_string(),
539                expiration: Utc::now() + chrono::Duration::minutes(30),
540                session_policies: Vec::new(),
541            },
542        );
543        let lookup = state.credential_secret("FSIATEMPKEY").unwrap();
544        assert_eq!(lookup.secret_access_key, "temp-secret");
545        assert_eq!(lookup.session_token.as_deref(), Some("temp-token"));
546        assert_eq!(
547            lookup.principal_arn,
548            "arn:aws:sts::123456789012:assumed-role/R/s"
549        );
550    }
551
552    #[test]
553    fn credential_secret_purges_expired_sts_credentials() {
554        let mut state = IamState::new("123456789012");
555        state.sts_temp_credentials.insert(
556            "FSIAOLD".to_string(),
557            StsTempCredential {
558                access_key_id: "FSIAOLD".to_string(),
559                secret_access_key: "s".to_string(),
560                session_token: "t".to_string(),
561                principal_arn: "arn".to_string(),
562                user_id: "id".to_string(),
563                account_id: "123456789012".to_string(),
564                expiration: Utc::now() - chrono::Duration::seconds(1),
565                session_policies: Vec::new(),
566            },
567        );
568        assert!(state.credential_secret("FSIAOLD").is_none());
569        assert!(!state.sts_temp_credentials.contains_key("FSIAOLD"));
570    }
571
572    #[test]
573    fn credential_secret_readonly_does_not_purge() {
574        let mut state = IamState::new("123456789012");
575        state.sts_temp_credentials.insert(
576            "FSIAOLD".to_string(),
577            StsTempCredential {
578                access_key_id: "FSIAOLD".to_string(),
579                secret_access_key: "s".to_string(),
580                session_token: "t".to_string(),
581                principal_arn: "arn".to_string(),
582                user_id: "id".to_string(),
583                account_id: "123456789012".to_string(),
584                expiration: Utc::now() - chrono::Duration::seconds(1),
585                session_policies: Vec::new(),
586            },
587        );
588        assert!(state.credential_secret_readonly("FSIAOLD").is_none());
589        assert!(state.sts_temp_credentials.contains_key("FSIAOLD"));
590    }
591
592    #[test]
593    fn credential_secret_returns_none_for_unknown_akid() {
594        let mut state = IamState::new("123456789012");
595        assert!(state.credential_secret("FKIAUNKNOWN").is_none());
596    }
597}