use crate::error::{AmiError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
#[derive(Debug, Clone)]
pub struct WamiArnBuilder {
salt: Option<String>,
}
impl WamiArnBuilder {
pub fn new() -> Self {
Self {
salt: std::env::var("WAMI_ARN_SALT").ok(),
}
}
pub fn with_salt(salt: impl Into<String>) -> Self {
Self {
salt: Some(salt.into()),
}
}
pub fn build_arn(
&self,
service: &str,
account_id: &str,
resource_type: &str,
path: &str,
name: &str,
) -> String {
let tenant_hash = self.hash_account(account_id);
format!(
"arn:wami:{}:{}:{}{}{}",
service, tenant_hash, resource_type, path, name
)
}
fn hash_account(&self, account_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(account_id.as_bytes());
if let Some(salt) = &self.salt {
hasher.update(salt.as_bytes());
}
let result = hasher.finalize();
format!("tenant-{}", hex::encode(&result[..4]))
}
pub fn salt(&self) -> Option<&str> {
self.salt.as_deref()
}
}
impl Default for WamiArnBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedArn {
pub provider: String,
pub service: String,
pub tenant_hash: String,
pub resource_type: String,
pub path: String,
pub name: String,
}
impl ParsedArn {
#[allow(clippy::result_large_err)]
pub fn from_arn(arn: &str) -> Result<Self> {
let parts: Vec<&str> = arn.split(':').collect();
if parts.len() < 5 {
return Err(AmiError::InvalidParameter {
message: format!("Invalid ARN format: {}", arn),
});
}
if parts[0] != "arn" {
return Err(AmiError::InvalidParameter {
message: format!("ARN must start with 'arn:', got: {}", arn),
});
}
if parts[1] != "wami" {
return Err(AmiError::InvalidParameter {
message: format!("Expected 'wami' provider, got: {}", parts[1]),
});
}
let provider = parts[1].to_string();
let service = parts[2].to_string();
let tenant_hash = parts[3].to_string();
let resource_path = parts[4];
let (resource_type, path, name) = Self::parse_resource_path(resource_path)?;
Ok(ParsedArn {
provider,
service,
tenant_hash,
resource_type,
path,
name,
})
}
#[allow(clippy::result_large_err)]
fn parse_resource_path(resource_path: &str) -> Result<(String, String, String)> {
let parts: Vec<&str> = resource_path.split('/').collect();
if parts.is_empty() {
return Err(AmiError::InvalidParameter {
message: "Empty resource path".to_string(),
});
}
let resource_type = parts[0].to_string();
if parts.len() == 1 {
return Err(AmiError::InvalidParameter {
message: format!("Missing resource name in: {}", resource_path),
});
}
if parts.len() == 2 {
return Ok((resource_type, String::new(), parts[1].to_string()));
}
let name = parts[parts.len() - 1].to_string();
let path_parts = &parts[1..parts.len() - 1];
let path = format!("/{}/", path_parts.join("/"));
Ok((resource_type, path, name))
}
pub fn to_arn(&self) -> String {
if self.path.is_empty() {
format!(
"arn:{}:{}:{}:{}/{}",
self.provider, self.service, self.tenant_hash, self.resource_type, self.name
)
} else {
format!(
"arn:{}:{}:{}:{}{}{}",
self.provider,
self.service,
self.tenant_hash,
self.resource_type,
self.path,
self.name
)
}
}
pub fn matches_pattern(&self, pattern: &str) -> bool {
arn_pattern_match(&self.to_arn(), pattern)
}
}
impl fmt::Display for ParsedArn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_arn())
}
}
pub fn arn_pattern_match(arn: &str, pattern: &str) -> bool {
let escaped = regex::escape(pattern);
let with_wildcards = escaped.replace(r"\*", ".*").replace(r"\?", ".");
let regex_pattern = format!("^{}$", with_wildcards);
if let Ok(re) = regex::Regex::new(®ex_pattern) {
re.is_match(arn)
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_arn_builder_basic() {
let builder = WamiArnBuilder::new();
let arn = builder.build_arn("iam", "123456789012", "user", "/", "alice");
assert!(arn.starts_with("arn:wami:iam:tenant-"));
assert!(arn.ends_with(":user/alice"));
}
#[test]
fn test_arn_builder_with_path() {
let builder = WamiArnBuilder::new();
let arn = builder.build_arn("iam", "123456789012", "user", "/tenants/acme/", "alice");
assert!(arn.contains("user/tenants/acme/alice"));
}
#[test]
fn test_arn_builder_deterministic() {
let builder = WamiArnBuilder::with_salt("test-salt");
let arn1 = builder.build_arn("iam", "123456789012", "user", "/", "alice");
let arn2 = builder.build_arn("iam", "123456789012", "user", "/", "bob");
let hash1 = arn1.split(':').nth(3).unwrap();
let hash2 = arn2.split(':').nth(3).unwrap();
assert_eq!(hash1, hash2);
}
#[test]
fn test_arn_builder_different_accounts() {
let builder = WamiArnBuilder::new();
let arn1 = builder.build_arn("iam", "123456789012", "user", "/", "alice");
let arn2 = builder.build_arn("iam", "999999999999", "user", "/", "alice");
let hash1 = arn1.split(':').nth(3).unwrap();
let hash2 = arn2.split(':').nth(3).unwrap();
assert_ne!(hash1, hash2);
}
#[test]
fn test_parse_arn_simple() {
let parsed = ParsedArn::from_arn("arn:wami:iam:tenant-abc123:user/alice").unwrap();
assert_eq!(parsed.provider, "wami");
assert_eq!(parsed.service, "iam");
assert_eq!(parsed.tenant_hash, "tenant-abc123");
assert_eq!(parsed.resource_type, "user");
assert_eq!(parsed.path, "");
assert_eq!(parsed.name, "alice");
}
#[test]
fn test_parse_arn_with_path() {
let parsed =
ParsedArn::from_arn("arn:wami:iam:tenant-abc:user/tenants/acme/engineering/alice")
.unwrap();
assert_eq!(parsed.resource_type, "user");
assert_eq!(parsed.path, "/tenants/acme/engineering/");
assert_eq!(parsed.name, "alice");
}
#[test]
fn test_parse_arn_roundtrip() {
let original = "arn:wami:iam:tenant-abc:user/tenants/acme/alice";
let parsed = ParsedArn::from_arn(original).unwrap();
let reconstructed = parsed.to_arn();
assert_eq!(original, reconstructed);
}
#[test]
fn test_arn_pattern_match_exact() {
assert!(arn_pattern_match(
"arn:wami:iam:tenant-abc:user/alice",
"arn:wami:iam:tenant-abc:user/alice"
));
}
#[test]
fn test_arn_pattern_match_wildcard() {
assert!(arn_pattern_match(
"arn:wami:iam:tenant-abc:user/alice",
"arn:wami:iam:tenant-abc:user/*"
));
assert!(arn_pattern_match(
"arn:wami:iam:tenant-abc:user/alice",
"arn:wami:iam:tenant-abc:*"
));
assert!(arn_pattern_match(
"arn:wami:iam:tenant-abc:user/alice",
"arn:wami:*:*:*"
));
}
#[test]
fn test_arn_pattern_no_match() {
assert!(!arn_pattern_match(
"arn:wami:iam:tenant-abc:user/alice",
"arn:wami:sts:*:*"
));
assert!(!arn_pattern_match(
"arn:wami:iam:tenant-abc:user/alice",
"arn:wami:iam:tenant-xyz:*"
));
}
#[test]
fn test_parsed_arn_matches_pattern() {
let arn = ParsedArn::from_arn("arn:wami:iam:tenant-abc:user/tenants/acme/alice").unwrap();
assert!(arn.matches_pattern("arn:wami:iam:tenant-abc:user/*"));
assert!(arn.matches_pattern("arn:wami:iam:tenant-abc:user/tenants/acme/*"));
assert!(!arn.matches_pattern("arn:wami:sts:*:*"));
}
}