use std::fmt;
use std::str::FromStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PrincipalType {
User,
AssumedRole,
FederatedUser,
Root,
Unknown,
}
impl PrincipalType {
pub fn as_str(self) -> &'static str {
match self {
PrincipalType::User => "user",
PrincipalType::AssumedRole => "assumed-role",
PrincipalType::FederatedUser => "federated-user",
PrincipalType::Root => "root",
PrincipalType::Unknown => "unknown",
}
}
pub fn from_arn(arn: &str) -> Self {
if arn.ends_with(":root") {
PrincipalType::Root
} else if arn.contains(":user/") {
PrincipalType::User
} else if arn.contains(":assumed-role/") {
PrincipalType::AssumedRole
} else if arn.contains(":federated-user/") {
PrincipalType::FederatedUser
} else {
PrincipalType::Unknown
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub arn: String,
pub user_id: String,
pub account_id: String,
pub principal_type: PrincipalType,
pub source_identity: Option<String>,
}
impl Principal {
pub fn is_root(&self) -> bool {
matches!(self.principal_type, PrincipalType::Root) || self.arn.ends_with(":root")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCredential {
pub secret_access_key: String,
pub session_token: Option<String>,
pub principal: Principal,
}
impl ResolvedCredential {
pub fn principal_arn(&self) -> &str {
&self.principal.arn
}
pub fn user_id(&self) -> &str {
&self.principal.user_id
}
pub fn account_id(&self) -> &str {
&self.principal.account_id
}
}
pub trait CredentialResolver: Send + Sync {
fn resolve(&self, access_key_id: &str) -> Option<ResolvedCredential>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IamAction {
pub service: &'static str,
pub action: &'static str,
pub resource: String,
}
impl IamAction {
pub fn action_string(&self) -> String {
format!("{}:{}", self.service, self.action)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IamDecision {
Allow,
ImplicitDeny,
ExplicitDeny,
}
impl IamDecision {
pub fn is_allow(self) -> bool {
matches!(self, IamDecision::Allow)
}
}
pub trait IamPolicyEvaluator: Send + Sync {
fn evaluate(&self, principal: &Principal, action: &IamAction) -> IamDecision;
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum IamMode {
#[default]
Off,
Soft,
Strict,
}
impl IamMode {
pub fn is_enabled(self) -> bool {
!matches!(self, IamMode::Off)
}
pub fn is_strict(self) -> bool {
matches!(self, IamMode::Strict)
}
pub fn as_str(self) -> &'static str {
match self {
IamMode::Off => "off",
IamMode::Soft => "soft",
IamMode::Strict => "strict",
}
}
}
impl fmt::Display for IamMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug)]
pub struct ParseIamModeError(String);
impl fmt::Display for ParseIamModeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid IAM mode `{}`; expected one of: off, soft, strict",
self.0
)
}
}
impl std::error::Error for ParseIamModeError {}
impl FromStr for IamMode {
type Err = ParseIamModeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"off" | "none" | "disabled" => Ok(IamMode::Off),
"soft" | "audit" | "warn" => Ok(IamMode::Soft),
"strict" | "enforce" | "deny" => Ok(IamMode::Strict),
other => Err(ParseIamModeError(other.to_string())),
}
}
}
pub fn is_root_bypass(access_key_id: &str) -> bool {
access_key_id
.trim()
.get(..4)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("test"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iam_mode_default_is_off() {
assert_eq!(IamMode::default(), IamMode::Off);
assert!(!IamMode::default().is_enabled());
}
#[test]
fn iam_mode_from_str_accepts_primary_values() {
assert_eq!(IamMode::from_str("off").unwrap(), IamMode::Off);
assert_eq!(IamMode::from_str("soft").unwrap(), IamMode::Soft);
assert_eq!(IamMode::from_str("strict").unwrap(), IamMode::Strict);
}
#[test]
fn iam_mode_from_str_is_case_insensitive_and_trimmed() {
assert_eq!(IamMode::from_str(" OFF ").unwrap(), IamMode::Off);
assert_eq!(IamMode::from_str("Soft").unwrap(), IamMode::Soft);
assert_eq!(IamMode::from_str("STRICT").unwrap(), IamMode::Strict);
}
#[test]
fn iam_mode_from_str_accepts_aliases() {
assert_eq!(IamMode::from_str("disabled").unwrap(), IamMode::Off);
assert_eq!(IamMode::from_str("audit").unwrap(), IamMode::Soft);
assert_eq!(IamMode::from_str("enforce").unwrap(), IamMode::Strict);
}
#[test]
fn iam_mode_from_str_rejects_garbage() {
assert!(IamMode::from_str("").is_err());
assert!(IamMode::from_str("allow").is_err());
assert!(IamMode::from_str("yes").is_err());
}
#[test]
fn iam_mode_display_roundtrips() {
for mode in [IamMode::Off, IamMode::Soft, IamMode::Strict] {
assert_eq!(IamMode::from_str(&mode.to_string()).unwrap(), mode);
}
}
#[test]
fn iam_mode_flags() {
assert!(!IamMode::Off.is_enabled());
assert!(!IamMode::Off.is_strict());
assert!(IamMode::Soft.is_enabled());
assert!(!IamMode::Soft.is_strict());
assert!(IamMode::Strict.is_enabled());
assert!(IamMode::Strict.is_strict());
}
#[test]
fn root_bypass_matches_test_prefix() {
assert!(is_root_bypass("test"));
assert!(is_root_bypass("TEST"));
assert!(is_root_bypass("Test"));
assert!(is_root_bypass("testAccessKey"));
assert!(is_root_bypass("TESTAKIAIOSFODNN7EXAMPLE"));
}
#[test]
fn root_bypass_does_not_panic_on_multibyte_input() {
assert!(!is_root_bypass("té"));
assert!(!is_root_bypass("日本語キー"));
assert!(!is_root_bypass("🔑🔑"));
}
#[test]
fn principal_type_from_arn_classifies_known_shapes() {
assert_eq!(
PrincipalType::from_arn("arn:aws:iam::123456789012:user/alice"),
PrincipalType::User
);
assert_eq!(
PrincipalType::from_arn("arn:aws:sts::123456789012:assumed-role/R/s"),
PrincipalType::AssumedRole
);
assert_eq!(
PrincipalType::from_arn("arn:aws:sts::123456789012:federated-user/bob"),
PrincipalType::FederatedUser
);
assert_eq!(
PrincipalType::from_arn("arn:aws:iam::123456789012:root"),
PrincipalType::Root
);
}
#[test]
fn principal_type_unparseable_is_unknown_not_root() {
assert_eq!(
PrincipalType::from_arn("not-an-arn"),
PrincipalType::Unknown
);
assert_eq!(PrincipalType::from_arn(""), PrincipalType::Unknown);
assert_eq!(
PrincipalType::from_arn("arn:aws:iam::123456789012:something-weird"),
PrincipalType::Unknown
);
let p = Principal {
arn: "garbage".to_string(),
user_id: "x".to_string(),
account_id: "123456789012".to_string(),
principal_type: PrincipalType::Unknown,
source_identity: None,
};
assert!(!p.is_root());
}
#[test]
fn principal_is_root_covers_root_type_and_arn_suffix() {
let p = Principal {
arn: "arn:aws:iam::123456789012:root".to_string(),
user_id: "AIDAROOT".to_string(),
account_id: "123456789012".to_string(),
principal_type: PrincipalType::Root,
source_identity: None,
};
assert!(p.is_root());
let user = Principal {
arn: "arn:aws:iam::123456789012:user/alice".to_string(),
user_id: "AIDAALICE".to_string(),
account_id: "123456789012".to_string(),
principal_type: PrincipalType::User,
source_identity: None,
};
assert!(!user.is_root());
}
#[test]
fn resolved_credential_accessors_forward_to_principal() {
let rc = ResolvedCredential {
secret_access_key: "s".into(),
session_token: None,
principal: Principal {
arn: "arn:aws:iam::123456789012:user/alice".into(),
user_id: "AIDAALICE".into(),
account_id: "123456789012".into(),
principal_type: PrincipalType::User,
source_identity: None,
},
};
assert_eq!(rc.principal_arn(), "arn:aws:iam::123456789012:user/alice");
assert_eq!(rc.user_id(), "AIDAALICE");
assert_eq!(rc.account_id(), "123456789012");
}
#[test]
fn root_bypass_rejects_non_test_keys() {
assert!(!is_root_bypass(""));
assert!(!is_root_bypass(" "));
assert!(!is_root_bypass("AKIAIOSFODNN7EXAMPLE"));
assert!(!is_root_bypass("FKIA123456"));
assert!(!is_root_bypass("tes"));
assert!(!is_root_bypass("tst"));
}
}