Skip to main content

fakecloud_iam/
credential_resolver.rs

1//! Adapter that implements [`fakecloud_core::auth::CredentialResolver`] over
2//! the shared IAM state.
3//!
4//! SigV4 verification (and later IAM enforcement) runs in `fakecloud-core`,
5//! which intentionally doesn't depend on `fakecloud-iam`. The trait lives in
6//! core and the concrete resolver lives here, keeping the dependency edge
7//! pointing the right way.
8
9use std::sync::Arc;
10
11use fakecloud_core::auth::{CredentialResolver, Principal, PrincipalType, ResolvedCredential};
12
13use crate::state::SharedIamState;
14
15/// [`CredentialResolver`] backed by an [`IamState`] shared via
16/// [`SharedIamState`]. Acquires a write lock on lookup so expired STS
17/// temporary credentials are purged in place.
18#[derive(Clone)]
19pub struct IamCredentialResolver {
20    state: SharedIamState,
21}
22
23impl IamCredentialResolver {
24    pub fn new(state: SharedIamState) -> Self {
25        Self { state }
26    }
27
28    pub fn shared(state: SharedIamState) -> Arc<dyn CredentialResolver> {
29        Arc::new(Self::new(state))
30    }
31}
32
33impl CredentialResolver for IamCredentialResolver {
34    fn resolve(&self, access_key_id: &str) -> Option<ResolvedCredential> {
35        let mut states = self.state.write();
36        // Search ALL accounts' credentials — a full scan is fine for a
37        // testing tool with a small number of accounts.
38        for (_, account_state) in states.iter_mut() {
39            if let Some(lookup) = account_state.credential_secret(access_key_id) {
40                let principal_type = PrincipalType::from_arn(&lookup.principal_arn);
41                return Some(ResolvedCredential {
42                    secret_access_key: lookup.secret_access_key,
43                    session_token: lookup.session_token,
44                    principal: Principal {
45                        arn: lookup.principal_arn,
46                        user_id: lookup.user_id,
47                        account_id: lookup.account_id,
48                        principal_type,
49                        source_identity: None,
50                        tags: lookup.principal_tags.map(|m| m.into_iter().collect()),
51                    },
52                    session_policies: lookup.session_policies,
53                    mfa_present: lookup.mfa_present,
54                    token_issued_at: lookup.token_issued_at,
55                    federated_provider: lookup.federated_provider,
56                });
57            }
58        }
59        None
60    }
61}
62
63fn _assert_impl<T: CredentialResolver>() {}
64const _: fn() = || {
65    _assert_impl::<IamCredentialResolver>();
66};
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::state::{IamAccessKey, IamState, IamUser};
72    use chrono::Utc;
73    use fakecloud_core::multi_account::MultiAccountState;
74    use parking_lot::RwLock;
75
76    /// Helper: create a `SharedIamState` (multi-account) pre-populated with
77    /// one account whose state is set up by the caller.
78    fn shared(state: IamState) -> SharedIamState {
79        let account_id = state.account_id.clone();
80        let mut mas = MultiAccountState::<IamState>::new(&account_id, "us-east-1", "");
81        // Replace the auto-created default with the caller's state.
82        *mas.get_or_create(&account_id) = state;
83        Arc::new(RwLock::new(mas))
84    }
85
86    #[test]
87    fn resolves_iam_user_secret_from_state() {
88        let mut state = IamState::new("123456789012");
89        state.users.insert(
90            "alice".to_string(),
91            IamUser {
92                user_name: "alice".into(),
93                user_id: "AIDAALICE".into(),
94                arn: "arn:aws:iam::123456789012:user/alice".into(),
95                path: "/".into(),
96                created_at: Utc::now(),
97                tags: Vec::new(),
98                permissions_boundary: None,
99            },
100        );
101        state.access_keys.insert(
102            "alice".to_string(),
103            vec![IamAccessKey {
104                access_key_id: "FKIAALICE".into(),
105                secret_access_key: "the-secret".into(),
106                user_name: "alice".into(),
107                status: "Active".into(),
108                created_at: Utc::now(),
109            }],
110        );
111        let resolver = IamCredentialResolver::new(shared(state));
112        let resolved = resolver.resolve("FKIAALICE").unwrap();
113        assert_eq!(resolved.secret_access_key, "the-secret");
114        assert_eq!(
115            resolved.principal.arn,
116            "arn:aws:iam::123456789012:user/alice"
117        );
118        assert_eq!(resolved.principal.principal_type, PrincipalType::User);
119        assert_eq!(resolved.session_token, None);
120    }
121
122    #[test]
123    fn returns_none_for_unknown_akid() {
124        let state = IamState::new("123456789012");
125        let resolver = IamCredentialResolver::new(shared(state));
126        assert!(resolver.resolve("FKIANONE").is_none());
127    }
128
129    #[test]
130    fn classifies_sts_assumed_role_principal() {
131        use crate::state::StsTempCredential;
132        let mut state = IamState::new("123456789012");
133        state.sts_temp_credentials.insert(
134            "FSIATEMP".to_string(),
135            StsTempCredential {
136                access_key_id: "FSIATEMP".into(),
137                secret_access_key: "temp-secret".into(),
138                session_token: "temp-token".into(),
139                principal_arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
140                user_id: "AROA:session".into(),
141                account_id: "123456789012".into(),
142                expiration: Utc::now() + chrono::Duration::minutes(30),
143                session_policies: Vec::new(),
144                mfa_present: false,
145                issued_at: Utc::now(),
146                federated_provider: None,
147            },
148        );
149        let resolver = IamCredentialResolver::new(shared(state));
150        let resolved = resolver.resolve("FSIATEMP").unwrap();
151        assert_eq!(
152            resolved.principal.principal_type,
153            PrincipalType::AssumedRole
154        );
155        assert_eq!(resolved.session_token.as_deref(), Some("temp-token"));
156    }
157
158    #[test]
159    fn resolves_across_accounts() {
160        let mas = MultiAccountState::<IamState>::new("111111111111", "us-east-1", "");
161        let shared_state: SharedIamState = Arc::new(RwLock::new(mas));
162
163        // Add user in account A
164        {
165            let mut states = shared_state.write();
166            let a = states.get_or_create("111111111111");
167            a.users.insert(
168                "alice".into(),
169                IamUser {
170                    user_name: "alice".into(),
171                    user_id: "AIDAALICE".into(),
172                    arn: "arn:aws:iam::111111111111:user/alice".into(),
173                    path: "/".into(),
174                    created_at: Utc::now(),
175                    tags: Vec::new(),
176                    permissions_boundary: None,
177                },
178            );
179            a.access_keys.insert(
180                "alice".into(),
181                vec![IamAccessKey {
182                    access_key_id: "FKIAALICE".into(),
183                    secret_access_key: "secret-a".into(),
184                    user_name: "alice".into(),
185                    status: "Active".into(),
186                    created_at: Utc::now(),
187                }],
188            );
189
190            // Add user in account B
191            let b = states.get_or_create("222222222222");
192            b.users.insert(
193                "bob".into(),
194                IamUser {
195                    user_name: "bob".into(),
196                    user_id: "AIDABOB".into(),
197                    arn: "arn:aws:iam::222222222222:user/bob".into(),
198                    path: "/".into(),
199                    created_at: Utc::now(),
200                    tags: Vec::new(),
201                    permissions_boundary: None,
202                },
203            );
204            b.access_keys.insert(
205                "bob".into(),
206                vec![IamAccessKey {
207                    access_key_id: "FKIABOB".into(),
208                    secret_access_key: "secret-b".into(),
209                    user_name: "bob".into(),
210                    status: "Active".into(),
211                    created_at: Utc::now(),
212                }],
213            );
214        }
215
216        let resolver = IamCredentialResolver::new(shared_state);
217
218        // Resolve from account A
219        let a = resolver.resolve("FKIAALICE").unwrap();
220        assert_eq!(a.principal.account_id, "111111111111");
221
222        // Resolve from account B
223        let b = resolver.resolve("FKIABOB").unwrap();
224        assert_eq!(b.principal.account_id, "222222222222");
225
226        // Unknown key
227        assert!(resolver.resolve("FKIANONE").is_none());
228    }
229
230    #[test]
231    fn resolves_iam_user_tags_for_principal() {
232        use crate::state::Tag;
233        let mut state = IamState::new("123456789012");
234        state.users.insert(
235            "bob".to_string(),
236            IamUser {
237                user_name: "bob".into(),
238                user_id: "AIDABOB".into(),
239                arn: "arn:aws:iam::123456789012:user/bob".into(),
240                path: "/".into(),
241                created_at: Utc::now(),
242                tags: vec![
243                    Tag {
244                        key: "Team".into(),
245                        value: "platform".into(),
246                    },
247                    Tag {
248                        key: "Environment".into(),
249                        value: "prod".into(),
250                    },
251                ],
252                permissions_boundary: None,
253            },
254        );
255        state.access_keys.insert(
256            "bob".to_string(),
257            vec![IamAccessKey {
258                access_key_id: "FKIABOB".into(),
259                secret_access_key: "bob-secret".into(),
260                user_name: "bob".into(),
261                status: "Active".into(),
262                created_at: Utc::now(),
263            }],
264        );
265        let resolver = IamCredentialResolver::new(shared(state));
266        let resolved = resolver.resolve("FKIABOB").unwrap();
267        let tags = resolved.principal.tags.as_ref().unwrap();
268        assert_eq!(tags.get("Team").map(|s| s.as_str()), Some("platform"));
269        assert_eq!(tags.get("Environment").map(|s| s.as_str()), Some("prod"));
270    }
271
272    #[test]
273    fn resolves_assumed_role_tags_for_principal() {
274        use crate::state::{IamRole, StsTempCredential, Tag};
275        let mut state = IamState::new("123456789012");
276        state.roles.insert(
277            "ops".to_string(),
278            IamRole {
279                role_name: "ops".into(),
280                role_id: "AROAOPS".into(),
281                arn: "arn:aws:iam::123456789012:role/ops".into(),
282                path: "/".into(),
283                assume_role_policy_document: "{}".into(),
284                created_at: Utc::now(),
285                tags: vec![Tag {
286                    key: "Department".into(),
287                    value: "engineering".into(),
288                }],
289                max_session_duration: 3600,
290                permissions_boundary: None,
291                description: None,
292            },
293        );
294        state.sts_temp_credentials.insert(
295            "FSIAOPS".to_string(),
296            StsTempCredential {
297                access_key_id: "FSIAOPS".into(),
298                secret_access_key: "ops-secret".into(),
299                session_token: "ops-token".into(),
300                principal_arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
301                user_id: "AROAOPS:session".into(),
302                account_id: "123456789012".into(),
303                expiration: Utc::now() + chrono::Duration::minutes(30),
304                session_policies: Vec::new(),
305                mfa_present: false,
306                issued_at: Utc::now(),
307                federated_provider: None,
308            },
309        );
310        let resolver = IamCredentialResolver::new(shared(state));
311        let resolved = resolver.resolve("FSIAOPS").unwrap();
312        let tags = resolved.principal.tags.as_ref().unwrap();
313        assert_eq!(
314            tags.get("Department").map(|s| s.as_str()),
315            Some("engineering")
316        );
317    }
318
319    #[test]
320    fn no_tags_yields_none() {
321        let mut state = IamState::new("123456789012");
322        state.users.insert(
323            "empty".to_string(),
324            IamUser {
325                user_name: "empty".into(),
326                user_id: "AIDAEMPTY".into(),
327                arn: "arn:aws:iam::123456789012:user/empty".into(),
328                path: "/".into(),
329                created_at: Utc::now(),
330                tags: Vec::new(),
331                permissions_boundary: None,
332            },
333        );
334        state.access_keys.insert(
335            "empty".to_string(),
336            vec![IamAccessKey {
337                access_key_id: "FKIAEMPTY".into(),
338                secret_access_key: "s".into(),
339                user_name: "empty".into(),
340                status: "Active".into(),
341                created_at: Utc::now(),
342            }],
343        );
344        let resolver = IamCredentialResolver::new(shared(state));
345        let resolved = resolver.resolve("FKIAEMPTY").unwrap();
346        assert!(resolved.principal.tags.is_none());
347    }
348}