1use std::fmt;
14use std::str::FromStr;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum PrincipalType {
24 User,
26 AssumedRole,
29 FederatedUser,
31 Root,
35 Unknown,
39}
40
41impl PrincipalType {
42 pub fn as_str(self) -> &'static str {
43 match self {
44 PrincipalType::User => "user",
45 PrincipalType::AssumedRole => "assumed-role",
46 PrincipalType::FederatedUser => "federated-user",
47 PrincipalType::Root => "root",
48 PrincipalType::Unknown => "unknown",
49 }
50 }
51
52 pub fn from_arn(arn: &str) -> Self {
59 if arn.ends_with(":root") {
60 PrincipalType::Root
61 } else if arn.contains(":user/") {
62 PrincipalType::User
63 } else if arn.contains(":assumed-role/") {
64 PrincipalType::AssumedRole
65 } else if arn.contains(":federated-user/") {
66 PrincipalType::FederatedUser
67 } else {
68 PrincipalType::Unknown
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Principal {
82 pub arn: String,
83 pub user_id: String,
84 pub account_id: String,
85 pub principal_type: PrincipalType,
86 pub source_identity: Option<String>,
90}
91
92impl Principal {
93 pub fn is_root(&self) -> bool {
96 matches!(self.principal_type, PrincipalType::Root) || self.arn.ends_with(":root")
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ResolvedCredential {
107 pub secret_access_key: String,
108 pub session_token: Option<String>,
109 pub principal: Principal,
110}
111
112impl ResolvedCredential {
113 pub fn principal_arn(&self) -> &str {
117 &self.principal.arn
118 }
119
120 pub fn user_id(&self) -> &str {
121 &self.principal.user_id
122 }
123
124 pub fn account_id(&self) -> &str {
125 &self.principal.account_id
126 }
127}
128
129pub trait CredentialResolver: Send + Sync {
137 fn resolve(&self, access_key_id: &str) -> Option<ResolvedCredential>;
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct IamAction {
153 pub service: &'static str,
155 pub action: &'static str,
157 pub resource: String,
159}
160
161impl IamAction {
162 pub fn action_string(&self) -> String {
165 format!("{}:{}", self.service, self.action)
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum IamDecision {
175 Allow,
176 ImplicitDeny,
177 ExplicitDeny,
178}
179
180impl IamDecision {
181 pub fn is_allow(self) -> bool {
182 matches!(self, IamDecision::Allow)
183 }
184}
185
186pub trait IamPolicyEvaluator: Send + Sync {
191 fn evaluate(&self, principal: &Principal, action: &IamAction) -> IamDecision;
194}
195
196#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
204pub enum IamMode {
205 #[default]
207 Off,
208 Soft,
211 Strict,
213}
214
215impl IamMode {
216 pub fn is_enabled(self) -> bool {
218 !matches!(self, IamMode::Off)
219 }
220
221 pub fn is_strict(self) -> bool {
223 matches!(self, IamMode::Strict)
224 }
225
226 pub fn as_str(self) -> &'static str {
227 match self {
228 IamMode::Off => "off",
229 IamMode::Soft => "soft",
230 IamMode::Strict => "strict",
231 }
232 }
233}
234
235impl fmt::Display for IamMode {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 f.write_str(self.as_str())
238 }
239}
240
241#[derive(Debug)]
243pub struct ParseIamModeError(String);
244
245impl fmt::Display for ParseIamModeError {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 write!(
248 f,
249 "invalid IAM mode `{}`; expected one of: off, soft, strict",
250 self.0
251 )
252 }
253}
254
255impl std::error::Error for ParseIamModeError {}
256
257impl FromStr for IamMode {
258 type Err = ParseIamModeError;
259
260 fn from_str(s: &str) -> Result<Self, Self::Err> {
261 match s.trim().to_ascii_lowercase().as_str() {
262 "off" | "none" | "disabled" => Ok(IamMode::Off),
263 "soft" | "audit" | "warn" => Ok(IamMode::Soft),
264 "strict" | "enforce" | "deny" => Ok(IamMode::Strict),
265 other => Err(ParseIamModeError(other.to_string())),
266 }
267 }
268}
269
270pub fn is_root_bypass(access_key_id: &str) -> bool {
282 access_key_id
283 .trim()
284 .get(..4)
285 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("test"))
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn iam_mode_default_is_off() {
294 assert_eq!(IamMode::default(), IamMode::Off);
295 assert!(!IamMode::default().is_enabled());
296 }
297
298 #[test]
299 fn iam_mode_from_str_accepts_primary_values() {
300 assert_eq!(IamMode::from_str("off").unwrap(), IamMode::Off);
301 assert_eq!(IamMode::from_str("soft").unwrap(), IamMode::Soft);
302 assert_eq!(IamMode::from_str("strict").unwrap(), IamMode::Strict);
303 }
304
305 #[test]
306 fn iam_mode_from_str_is_case_insensitive_and_trimmed() {
307 assert_eq!(IamMode::from_str(" OFF ").unwrap(), IamMode::Off);
308 assert_eq!(IamMode::from_str("Soft").unwrap(), IamMode::Soft);
309 assert_eq!(IamMode::from_str("STRICT").unwrap(), IamMode::Strict);
310 }
311
312 #[test]
313 fn iam_mode_from_str_accepts_aliases() {
314 assert_eq!(IamMode::from_str("disabled").unwrap(), IamMode::Off);
315 assert_eq!(IamMode::from_str("audit").unwrap(), IamMode::Soft);
316 assert_eq!(IamMode::from_str("enforce").unwrap(), IamMode::Strict);
317 }
318
319 #[test]
320 fn iam_mode_from_str_rejects_garbage() {
321 assert!(IamMode::from_str("").is_err());
322 assert!(IamMode::from_str("allow").is_err());
323 assert!(IamMode::from_str("yes").is_err());
324 }
325
326 #[test]
327 fn iam_mode_display_roundtrips() {
328 for mode in [IamMode::Off, IamMode::Soft, IamMode::Strict] {
329 assert_eq!(IamMode::from_str(&mode.to_string()).unwrap(), mode);
330 }
331 }
332
333 #[test]
334 fn iam_mode_flags() {
335 assert!(!IamMode::Off.is_enabled());
336 assert!(!IamMode::Off.is_strict());
337 assert!(IamMode::Soft.is_enabled());
338 assert!(!IamMode::Soft.is_strict());
339 assert!(IamMode::Strict.is_enabled());
340 assert!(IamMode::Strict.is_strict());
341 }
342
343 #[test]
344 fn root_bypass_matches_test_prefix() {
345 assert!(is_root_bypass("test"));
346 assert!(is_root_bypass("TEST"));
347 assert!(is_root_bypass("Test"));
348 assert!(is_root_bypass("testAccessKey"));
349 assert!(is_root_bypass("TESTAKIAIOSFODNN7EXAMPLE"));
350 }
351
352 #[test]
353 fn root_bypass_does_not_panic_on_multibyte_input() {
354 assert!(!is_root_bypass("té"));
356 assert!(!is_root_bypass("日本語キー"));
357 assert!(!is_root_bypass("🔑🔑"));
358 }
359
360 #[test]
361 fn principal_type_from_arn_classifies_known_shapes() {
362 assert_eq!(
363 PrincipalType::from_arn("arn:aws:iam::123456789012:user/alice"),
364 PrincipalType::User
365 );
366 assert_eq!(
367 PrincipalType::from_arn("arn:aws:sts::123456789012:assumed-role/R/s"),
368 PrincipalType::AssumedRole
369 );
370 assert_eq!(
371 PrincipalType::from_arn("arn:aws:sts::123456789012:federated-user/bob"),
372 PrincipalType::FederatedUser
373 );
374 assert_eq!(
375 PrincipalType::from_arn("arn:aws:iam::123456789012:root"),
376 PrincipalType::Root
377 );
378 }
379
380 #[test]
381 fn principal_type_unparseable_is_unknown_not_root() {
382 assert_eq!(
387 PrincipalType::from_arn("not-an-arn"),
388 PrincipalType::Unknown
389 );
390 assert_eq!(PrincipalType::from_arn(""), PrincipalType::Unknown);
391 assert_eq!(
392 PrincipalType::from_arn("arn:aws:iam::123456789012:something-weird"),
393 PrincipalType::Unknown
394 );
395
396 let p = Principal {
399 arn: "garbage".to_string(),
400 user_id: "x".to_string(),
401 account_id: "123456789012".to_string(),
402 principal_type: PrincipalType::Unknown,
403 source_identity: None,
404 };
405 assert!(!p.is_root());
406 }
407
408 #[test]
409 fn principal_is_root_covers_root_type_and_arn_suffix() {
410 let p = Principal {
411 arn: "arn:aws:iam::123456789012:root".to_string(),
412 user_id: "AIDAROOT".to_string(),
413 account_id: "123456789012".to_string(),
414 principal_type: PrincipalType::Root,
415 source_identity: None,
416 };
417 assert!(p.is_root());
418
419 let user = Principal {
420 arn: "arn:aws:iam::123456789012:user/alice".to_string(),
421 user_id: "AIDAALICE".to_string(),
422 account_id: "123456789012".to_string(),
423 principal_type: PrincipalType::User,
424 source_identity: None,
425 };
426 assert!(!user.is_root());
427 }
428
429 #[test]
430 fn resolved_credential_accessors_forward_to_principal() {
431 let rc = ResolvedCredential {
432 secret_access_key: "s".into(),
433 session_token: None,
434 principal: Principal {
435 arn: "arn:aws:iam::123456789012:user/alice".into(),
436 user_id: "AIDAALICE".into(),
437 account_id: "123456789012".into(),
438 principal_type: PrincipalType::User,
439 source_identity: None,
440 },
441 };
442 assert_eq!(rc.principal_arn(), "arn:aws:iam::123456789012:user/alice");
443 assert_eq!(rc.user_id(), "AIDAALICE");
444 assert_eq!(rc.account_id(), "123456789012");
445 }
446
447 #[test]
448 fn root_bypass_rejects_non_test_keys() {
449 assert!(!is_root_bypass(""));
450 assert!(!is_root_bypass(" "));
451 assert!(!is_root_bypass("AKIAIOSFODNN7EXAMPLE"));
452 assert!(!is_root_bypass("FKIA123456"));
453 assert!(!is_root_bypass("tes"));
454 assert!(!is_root_bypass("tst"));
455 }
456}