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
60fn _assert_impl<T: CredentialResolver>() {}
61const _: fn() = || {
62    _assert_impl::<IamCredentialResolver>();
63};
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::state::{IamAccessKey, IamState, IamUser};
69    use chrono::Utc;
70    use fakecloud_core::multi_account::MultiAccountState;
71    use parking_lot::RwLock;
72
73    /// Helper: create a `SharedIamState` (multi-account) pre-populated with
74    /// one account whose state is set up by the caller.
75    fn shared(state: IamState) -> SharedIamState {
76        let account_id = state.account_id.clone();
77        let mut mas = MultiAccountState::<IamState>::new(&account_id, "us-east-1", "");
78        // Replace the auto-created default with the caller's state.
79        *mas.get_or_create(&account_id) = state;
80        Arc::new(RwLock::new(mas))
81    }
82
83    #[test]
84    fn resolves_iam_user_secret_from_state() {
85        let mut state = IamState::new("123456789012");
86        state.users.insert(
87            "alice".to_string(),
88            IamUser {
89                user_name: "alice".into(),
90                user_id: "AIDAALICE".into(),
91                arn: "arn:aws:iam::123456789012:user/alice".into(),
92                path: "/".into(),
93                created_at: Utc::now(),
94                tags: Vec::new(),
95                permissions_boundary: None,
96            },
97        );
98        state.access_keys.insert(
99            "alice".to_string(),
100            vec![IamAccessKey {
101                access_key_id: "FKIAALICE".into(),
102                secret_access_key: "the-secret".into(),
103                user_name: "alice".into(),
104                status: "Active".into(),
105                created_at: Utc::now(),
106            }],
107        );
108        let resolver = IamCredentialResolver::new(shared(state));
109        let resolved = resolver.resolve("FKIAALICE").unwrap();
110        assert_eq!(resolved.secret_access_key, "the-secret");
111        assert_eq!(
112            resolved.principal.arn,
113            "arn:aws:iam::123456789012:user/alice"
114        );
115        assert_eq!(resolved.principal.principal_type, PrincipalType::User);
116        assert_eq!(resolved.session_token, None);
117    }
118
119    #[test]
120    fn returns_none_for_unknown_akid() {
121        let state = IamState::new("123456789012");
122        let resolver = IamCredentialResolver::new(shared(state));
123        assert!(resolver.resolve("FKIANONE").is_none());
124    }
125
126    #[test]
127    fn classifies_sts_assumed_role_principal() {
128        use crate::state::StsTempCredential;
129        let mut state = IamState::new("123456789012");
130        state.sts_temp_credentials.insert(
131            "FSIATEMP".to_string(),
132            StsTempCredential {
133                access_key_id: "FSIATEMP".into(),
134                secret_access_key: "temp-secret".into(),
135                session_token: "temp-token".into(),
136                principal_arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
137                user_id: "AROA:session".into(),
138                account_id: "123456789012".into(),
139                expiration: Utc::now() + chrono::Duration::minutes(30),
140                session_policies: Vec::new(),
141            },
142        );
143        let resolver = IamCredentialResolver::new(shared(state));
144        let resolved = resolver.resolve("FSIATEMP").unwrap();
145        assert_eq!(
146            resolved.principal.principal_type,
147            PrincipalType::AssumedRole
148        );
149        assert_eq!(resolved.session_token.as_deref(), Some("temp-token"));
150    }
151
152    #[test]
153    fn resolves_across_accounts() {
154        let mas = MultiAccountState::<IamState>::new("111111111111", "us-east-1", "");
155        let shared_state: SharedIamState = Arc::new(RwLock::new(mas));
156
157        // Add user in account A
158        {
159            let mut states = shared_state.write();
160            let a = states.get_or_create("111111111111");
161            a.users.insert(
162                "alice".into(),
163                IamUser {
164                    user_name: "alice".into(),
165                    user_id: "AIDAALICE".into(),
166                    arn: "arn:aws:iam::111111111111:user/alice".into(),
167                    path: "/".into(),
168                    created_at: Utc::now(),
169                    tags: Vec::new(),
170                    permissions_boundary: None,
171                },
172            );
173            a.access_keys.insert(
174                "alice".into(),
175                vec![IamAccessKey {
176                    access_key_id: "FKIAALICE".into(),
177                    secret_access_key: "secret-a".into(),
178                    user_name: "alice".into(),
179                    status: "Active".into(),
180                    created_at: Utc::now(),
181                }],
182            );
183
184            // Add user in account B
185            let b = states.get_or_create("222222222222");
186            b.users.insert(
187                "bob".into(),
188                IamUser {
189                    user_name: "bob".into(),
190                    user_id: "AIDABOB".into(),
191                    arn: "arn:aws:iam::222222222222:user/bob".into(),
192                    path: "/".into(),
193                    created_at: Utc::now(),
194                    tags: Vec::new(),
195                    permissions_boundary: None,
196                },
197            );
198            b.access_keys.insert(
199                "bob".into(),
200                vec![IamAccessKey {
201                    access_key_id: "FKIABOB".into(),
202                    secret_access_key: "secret-b".into(),
203                    user_name: "bob".into(),
204                    status: "Active".into(),
205                    created_at: Utc::now(),
206                }],
207            );
208        }
209
210        let resolver = IamCredentialResolver::new(shared_state);
211
212        // Resolve from account A
213        let a = resolver.resolve("FKIAALICE").unwrap();
214        assert_eq!(a.principal.account_id, "111111111111");
215
216        // Resolve from account B
217        let b = resolver.resolve("FKIABOB").unwrap();
218        assert_eq!(b.principal.account_id, "222222222222");
219
220        // Unknown key
221        assert!(resolver.resolve("FKIANONE").is_none());
222    }
223
224    #[test]
225    fn resolves_iam_user_tags_for_principal() {
226        use crate::state::Tag;
227        let mut state = IamState::new("123456789012");
228        state.users.insert(
229            "bob".to_string(),
230            IamUser {
231                user_name: "bob".into(),
232                user_id: "AIDABOB".into(),
233                arn: "arn:aws:iam::123456789012:user/bob".into(),
234                path: "/".into(),
235                created_at: Utc::now(),
236                tags: vec![
237                    Tag {
238                        key: "Team".into(),
239                        value: "platform".into(),
240                    },
241                    Tag {
242                        key: "Environment".into(),
243                        value: "prod".into(),
244                    },
245                ],
246                permissions_boundary: None,
247            },
248        );
249        state.access_keys.insert(
250            "bob".to_string(),
251            vec![IamAccessKey {
252                access_key_id: "FKIABOB".into(),
253                secret_access_key: "bob-secret".into(),
254                user_name: "bob".into(),
255                status: "Active".into(),
256                created_at: Utc::now(),
257            }],
258        );
259        let resolver = IamCredentialResolver::new(shared(state));
260        let resolved = resolver.resolve("FKIABOB").unwrap();
261        let tags = resolved.principal.tags.as_ref().unwrap();
262        assert_eq!(tags.get("Team").map(|s| s.as_str()), Some("platform"));
263        assert_eq!(tags.get("Environment").map(|s| s.as_str()), Some("prod"));
264    }
265
266    #[test]
267    fn resolves_assumed_role_tags_for_principal() {
268        use crate::state::{IamRole, StsTempCredential, Tag};
269        let mut state = IamState::new("123456789012");
270        state.roles.insert(
271            "ops".to_string(),
272            IamRole {
273                role_name: "ops".into(),
274                role_id: "AROAOPS".into(),
275                arn: "arn:aws:iam::123456789012:role/ops".into(),
276                path: "/".into(),
277                assume_role_policy_document: "{}".into(),
278                created_at: Utc::now(),
279                tags: vec![Tag {
280                    key: "Department".into(),
281                    value: "engineering".into(),
282                }],
283                max_session_duration: 3600,
284                permissions_boundary: None,
285                description: None,
286            },
287        );
288        state.sts_temp_credentials.insert(
289            "FSIAOPS".to_string(),
290            StsTempCredential {
291                access_key_id: "FSIAOPS".into(),
292                secret_access_key: "ops-secret".into(),
293                session_token: "ops-token".into(),
294                principal_arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
295                user_id: "AROAOPS:session".into(),
296                account_id: "123456789012".into(),
297                expiration: Utc::now() + chrono::Duration::minutes(30),
298                session_policies: Vec::new(),
299            },
300        );
301        let resolver = IamCredentialResolver::new(shared(state));
302        let resolved = resolver.resolve("FSIAOPS").unwrap();
303        let tags = resolved.principal.tags.as_ref().unwrap();
304        assert_eq!(
305            tags.get("Department").map(|s| s.as_str()),
306            Some("engineering")
307        );
308    }
309
310    #[test]
311    fn no_tags_yields_none() {
312        let mut state = IamState::new("123456789012");
313        state.users.insert(
314            "empty".to_string(),
315            IamUser {
316                user_name: "empty".into(),
317                user_id: "AIDAEMPTY".into(),
318                arn: "arn:aws:iam::123456789012:user/empty".into(),
319                path: "/".into(),
320                created_at: Utc::now(),
321                tags: Vec::new(),
322                permissions_boundary: None,
323            },
324        );
325        state.access_keys.insert(
326            "empty".to_string(),
327            vec![IamAccessKey {
328                access_key_id: "FKIAEMPTY".into(),
329                secret_access_key: "s".into(),
330                user_name: "empty".into(),
331                status: "Active".into(),
332                created_at: Utc::now(),
333            }],
334        );
335        let resolver = IamCredentialResolver::new(shared(state));
336        let resolved = resolver.resolve("FKIAEMPTY").unwrap();
337        assert!(resolved.principal.tags.is_none());
338    }
339}