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,
51                    },
52                    session_policies: lookup.session_policies,
53                });
54            }
55        }
56        None
57    }
58}
59
60// Prevent rustc from warning on the unused import when the module is
61// included via `lib.rs` without tests referencing it.
62#[allow(dead_code)]
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            },
145        );
146        let resolver = IamCredentialResolver::new(shared(state));
147        let resolved = resolver.resolve("FSIATEMP").unwrap();
148        assert_eq!(
149            resolved.principal.principal_type,
150            PrincipalType::AssumedRole
151        );
152        assert_eq!(resolved.session_token.as_deref(), Some("temp-token"));
153    }
154
155    #[test]
156    fn resolves_across_accounts() {
157        let mas = MultiAccountState::<IamState>::new("111111111111", "us-east-1", "");
158        let shared_state: SharedIamState = Arc::new(RwLock::new(mas));
159
160        // Add user in account A
161        {
162            let mut states = shared_state.write();
163            let a = states.get_or_create("111111111111");
164            a.users.insert(
165                "alice".into(),
166                IamUser {
167                    user_name: "alice".into(),
168                    user_id: "AIDAALICE".into(),
169                    arn: "arn:aws:iam::111111111111:user/alice".into(),
170                    path: "/".into(),
171                    created_at: Utc::now(),
172                    tags: Vec::new(),
173                    permissions_boundary: None,
174                },
175            );
176            a.access_keys.insert(
177                "alice".into(),
178                vec![IamAccessKey {
179                    access_key_id: "FKIAALICE".into(),
180                    secret_access_key: "secret-a".into(),
181                    user_name: "alice".into(),
182                    status: "Active".into(),
183                    created_at: Utc::now(),
184                }],
185            );
186
187            // Add user in account B
188            let b = states.get_or_create("222222222222");
189            b.users.insert(
190                "bob".into(),
191                IamUser {
192                    user_name: "bob".into(),
193                    user_id: "AIDABOB".into(),
194                    arn: "arn:aws:iam::222222222222:user/bob".into(),
195                    path: "/".into(),
196                    created_at: Utc::now(),
197                    tags: Vec::new(),
198                    permissions_boundary: None,
199                },
200            );
201            b.access_keys.insert(
202                "bob".into(),
203                vec![IamAccessKey {
204                    access_key_id: "FKIABOB".into(),
205                    secret_access_key: "secret-b".into(),
206                    user_name: "bob".into(),
207                    status: "Active".into(),
208                    created_at: Utc::now(),
209                }],
210            );
211        }
212
213        let resolver = IamCredentialResolver::new(shared_state);
214
215        // Resolve from account A
216        let a = resolver.resolve("FKIAALICE").unwrap();
217        assert_eq!(a.principal.account_id, "111111111111");
218
219        // Resolve from account B
220        let b = resolver.resolve("FKIABOB").unwrap();
221        assert_eq!(b.principal.account_id, "222222222222");
222
223        // Unknown key
224        assert!(resolver.resolve("FKIANONE").is_none());
225    }
226
227    #[test]
228    fn resolves_iam_user_tags_for_principal() {
229        use crate::state::Tag;
230        let mut state = IamState::new("123456789012");
231        state.users.insert(
232            "bob".to_string(),
233            IamUser {
234                user_name: "bob".into(),
235                user_id: "AIDABOB".into(),
236                arn: "arn:aws:iam::123456789012:user/bob".into(),
237                path: "/".into(),
238                created_at: Utc::now(),
239                tags: vec![
240                    Tag {
241                        key: "Team".into(),
242                        value: "platform".into(),
243                    },
244                    Tag {
245                        key: "Environment".into(),
246                        value: "prod".into(),
247                    },
248                ],
249                permissions_boundary: None,
250            },
251        );
252        state.access_keys.insert(
253            "bob".to_string(),
254            vec![IamAccessKey {
255                access_key_id: "FKIABOB".into(),
256                secret_access_key: "bob-secret".into(),
257                user_name: "bob".into(),
258                status: "Active".into(),
259                created_at: Utc::now(),
260            }],
261        );
262        let resolver = IamCredentialResolver::new(shared(state));
263        let resolved = resolver.resolve("FKIABOB").unwrap();
264        let tags = resolved.principal.tags.as_ref().unwrap();
265        assert_eq!(tags.get("Team").map(|s| s.as_str()), Some("platform"));
266        assert_eq!(tags.get("Environment").map(|s| s.as_str()), Some("prod"));
267    }
268
269    #[test]
270    fn resolves_assumed_role_tags_for_principal() {
271        use crate::state::{IamRole, StsTempCredential, Tag};
272        let mut state = IamState::new("123456789012");
273        state.roles.insert(
274            "ops".to_string(),
275            IamRole {
276                role_name: "ops".into(),
277                role_id: "AROAOPS".into(),
278                arn: "arn:aws:iam::123456789012:role/ops".into(),
279                path: "/".into(),
280                assume_role_policy_document: "{}".into(),
281                created_at: Utc::now(),
282                tags: vec![Tag {
283                    key: "Department".into(),
284                    value: "engineering".into(),
285                }],
286                max_session_duration: 3600,
287                permissions_boundary: None,
288                description: None,
289            },
290        );
291        state.sts_temp_credentials.insert(
292            "FSIAOPS".to_string(),
293            StsTempCredential {
294                access_key_id: "FSIAOPS".into(),
295                secret_access_key: "ops-secret".into(),
296                session_token: "ops-token".into(),
297                principal_arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
298                user_id: "AROAOPS:session".into(),
299                account_id: "123456789012".into(),
300                expiration: Utc::now() + chrono::Duration::minutes(30),
301                session_policies: Vec::new(),
302            },
303        );
304        let resolver = IamCredentialResolver::new(shared(state));
305        let resolved = resolver.resolve("FSIAOPS").unwrap();
306        let tags = resolved.principal.tags.as_ref().unwrap();
307        assert_eq!(
308            tags.get("Department").map(|s| s.as_str()),
309            Some("engineering")
310        );
311    }
312
313    #[test]
314    fn no_tags_yields_none() {
315        let mut state = IamState::new("123456789012");
316        state.users.insert(
317            "empty".to_string(),
318            IamUser {
319                user_name: "empty".into(),
320                user_id: "AIDAEMPTY".into(),
321                arn: "arn:aws:iam::123456789012:user/empty".into(),
322                path: "/".into(),
323                created_at: Utc::now(),
324                tags: Vec::new(),
325                permissions_boundary: None,
326            },
327        );
328        state.access_keys.insert(
329            "empty".to_string(),
330            vec![IamAccessKey {
331                access_key_id: "FKIAEMPTY".into(),
332                secret_access_key: "s".into(),
333                user_name: "empty".into(),
334                status: "Active".into(),
335                created_at: Utc::now(),
336            }],
337        );
338        let resolver = IamCredentialResolver::new(shared(state));
339        let resolved = resolver.resolve("FKIAEMPTY").unwrap();
340        assert!(resolved.principal.tags.is_none());
341    }
342}