use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WamiArn {
pub service: Service,
pub tenant_path: TenantPath,
pub wami_instance_id: String,
pub cloud_mapping: Option<CloudMapping>,
pub resource: Resource,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TenantPath {
pub segments: Vec<u64>,
}
impl TenantPath {
pub fn new(segments: Vec<u64>) -> Self {
Self { segments }
}
pub fn single(tenant_id: u64) -> Self {
Self {
segments: vec![tenant_id],
}
}
pub fn from_segments<I>(segments: I) -> Self
where
I: Into<Vec<u64>>,
{
Self {
segments: segments.into(),
}
}
pub fn as_string(&self) -> String {
self.segments
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join("/")
}
pub fn root(&self) -> Option<String> {
self.segments.first().map(|n| n.to_string())
}
pub fn leaf(&self) -> Option<String> {
self.segments.last().map(|n| n.to_string())
}
pub fn root_u64(&self) -> Option<u64> {
self.segments.first().copied()
}
pub fn leaf_u64(&self) -> Option<u64> {
self.segments.last().copied()
}
pub fn depth(&self) -> usize {
self.segments.len()
}
pub fn starts_with(&self, other: &TenantPath) -> bool {
if self.segments.len() < other.segments.len() {
return false;
}
for (i, segment) in other.segments.iter().enumerate() {
if self.segments[i] != *segment {
return false;
}
}
true
}
pub fn is_descendant_of(&self, other: &TenantPath) -> bool {
if self.segments.len() <= other.segments.len() {
return false;
}
self.segments
.iter()
.zip(other.segments.iter())
.all(|(a, b)| a == b)
}
pub fn is_ancestor_of(&self, other: &TenantPath) -> bool {
other.is_descendant_of(self)
}
}
impl Serialize for TenantPath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.as_string())
}
}
impl<'de> Deserialize<'de> for TenantPath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
return Err(serde::de::Error::custom("Tenant path cannot be empty"));
}
let segments: Result<Vec<u64>, _> = s
.split('/')
.map(|seg| {
seg.parse::<u64>().map_err(|_| {
serde::de::Error::custom(format!(
"Invalid tenant path segment: '{}' (must be a u64)",
seg
))
})
})
.collect();
Ok(Self {
segments: segments?,
})
}
}
impl fmt::Display for TenantPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CloudMapping {
pub provider: String,
pub account_id: String,
pub region: Option<String>,
}
impl CloudMapping {
pub fn new(provider: impl Into<String>, account_id: impl Into<String>) -> Self {
Self {
provider: provider.into(),
account_id: account_id.into(),
region: None,
}
}
pub fn with_region(
provider: impl Into<String>,
account_id: impl Into<String>,
region: impl Into<String>,
) -> Self {
Self {
provider: provider.into(),
account_id: account_id.into(),
region: Some(region.into()),
}
}
pub fn is_regional(&self) -> bool {
self.region.is_some()
}
pub fn region_or_global(&self) -> &str {
self.region.as_deref().unwrap_or("global")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Resource {
pub resource_type: String,
pub resource_id: String,
}
impl Resource {
pub fn new(resource_type: impl Into<String>, resource_id: impl Into<String>) -> Self {
Self {
resource_type: resource_type.into(),
resource_id: resource_id.into(),
}
}
pub fn as_path(&self) -> String {
format!("{}/{}", self.resource_type, self.resource_id)
}
}
impl fmt::Display for Resource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_path())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Service {
Iam,
Sts,
#[serde(rename = "sso-admin")]
SsoAdmin,
Custom(String),
}
impl Service {
pub fn as_str(&self) -> &str {
match self {
Service::Iam => "iam",
Service::Sts => "sts",
Service::SsoAdmin => "sso-admin",
Service::Custom(s) => s.as_str(),
}
}
}
impl fmt::Display for Service {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl From<&str> for Service {
fn from(s: &str) -> Self {
match s {
"iam" => Service::Iam,
"sts" => Service::Sts,
"sso-admin" => Service::SsoAdmin,
other => Service::Custom(other.to_string()),
}
}
}
impl WamiArn {
pub fn prefix(&self) -> String {
let base = format!(
"arn:wami:{}:{}:wami:{}",
self.service, self.tenant_path, self.wami_instance_id
);
if let Some(ref mapping) = self.cloud_mapping {
let region = mapping.region_or_global();
format!(
"{}:{}:{}:{}",
base, mapping.provider, mapping.account_id, region
)
} else {
base
}
}
pub fn is_cloud_synced(&self) -> bool {
self.cloud_mapping.is_some()
}
pub fn provider(&self) -> Option<&str> {
self.cloud_mapping.as_ref().map(|m| m.provider.as_str())
}
pub fn primary_tenant(&self) -> Option<String> {
self.tenant_path.root()
}
pub fn leaf_tenant(&self) -> Option<String> {
self.tenant_path.leaf()
}
pub fn full_tenant_path(&self) -> String {
self.tenant_path.as_string()
}
pub fn matches_prefix(&self, prefix: &str) -> bool {
self.to_string().starts_with(prefix)
}
pub fn belongs_to_tenant(&self, tenant_path: &TenantPath) -> bool {
&self.tenant_path == tenant_path || self.tenant_path.is_descendant_of(tenant_path)
}
pub fn resource_type(&self) -> &str {
&self.resource.resource_type
}
pub fn resource_id(&self) -> &str {
&self.resource.resource_id
}
}
impl fmt::Display for WamiArn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.prefix(), self.resource)
}
}
impl Serialize for WamiArn {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WamiArn {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
WamiArn::from_str(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tenant_path_single() {
let path = TenantPath::single(12345678);
assert_eq!(path.segments, vec![12345678]);
assert_eq!(path.as_string(), "12345678");
assert_eq!(path.root(), Some("12345678".to_string()));
assert_eq!(path.leaf(), Some("12345678".to_string()));
assert_eq!(path.depth(), 1);
}
#[test]
fn test_tenant_path_hierarchy() {
let path = TenantPath::new(vec![12345678, 87654321, 99999999]);
assert_eq!(path.as_string(), "12345678/87654321/99999999");
assert_eq!(path.root(), Some("12345678".to_string()));
assert_eq!(path.leaf(), Some("99999999".to_string()));
assert_eq!(path.depth(), 3);
}
#[test]
fn test_tenant_path_descendant() {
let parent = TenantPath::new(vec![12345678, 87654321]);
let child = TenantPath::new(vec![12345678, 87654321, 99999999]);
let other = TenantPath::new(vec![12345678, 11111111]);
assert!(child.is_descendant_of(&parent));
assert!(parent.is_ancestor_of(&child));
assert!(!other.is_descendant_of(&parent));
assert!(!parent.is_descendant_of(&child));
}
#[test]
fn test_tenant_path_starts_with() {
let path1 = TenantPath::new(vec![12345678, 87654321, 99999999]);
let path2 = TenantPath::new(vec![12345678, 87654321]);
let path3 = TenantPath::new(vec![12345678]);
let path4 = TenantPath::new(vec![12345678, 87654321, 99999999, 11111111]);
let path5 = TenantPath::new(vec![12345678, 55555555]);
assert!(path1.starts_with(&path2));
assert!(path1.starts_with(&path3));
assert!(path4.starts_with(&path1));
assert!(path1.starts_with(&path1)); assert!(!path1.starts_with(&path5)); assert!(!path2.starts_with(&path1)); }
#[test]
fn test_tenant_path_empty_root_leaf() {
let empty_path = TenantPath::new(vec![]);
assert_eq!(empty_path.root(), None);
assert_eq!(empty_path.leaf(), None);
assert_eq!(empty_path.depth(), 0);
assert_eq!(empty_path.as_string(), "");
}
#[test]
fn test_cloud_mapping_global_vs_regional() {
let global = CloudMapping::new("aws", "123456789012");
assert!(!global.is_regional());
assert_eq!(global.region_or_global(), "global");
let regional = CloudMapping::with_region("aws", "123456789012", "us-east-1");
assert!(regional.is_regional());
assert_eq!(regional.region_or_global(), "us-east-1");
}
#[test]
fn test_wami_arn_matches_prefix() {
let arn: WamiArn = "arn:wami:iam:12345678/87654321/99999999:wami:999888777:user/alice"
.parse()
.unwrap();
assert!(arn.matches_prefix("arn:wami:iam:12345678/87654321/99999999:wami:999888777"));
assert!(arn.matches_prefix("arn:wami:iam:12345678/87654321"));
assert!(arn.matches_prefix("arn:wami:iam:12345678"));
assert!(arn.matches_prefix("arn:wami:iam"));
assert!(arn.matches_prefix("arn:wami"));
assert!(arn.matches_prefix("arn"));
assert!(!arn.matches_prefix("arn:wami:sts"));
assert!(!arn.matches_prefix("arn:wami:iam:12345678/87654321/11111111"));
}
#[test]
fn test_wami_arn_belongs_to_tenant() {
let arn: WamiArn = "arn:wami:iam:12345678/87654321/99999999:wami:999888777:user/alice"
.parse()
.unwrap();
let tenant = TenantPath::new(vec![12345678, 87654321, 99999999]);
assert!(arn.belongs_to_tenant(&tenant));
let parent = TenantPath::new(vec![12345678, 87654321]);
assert!(arn.belongs_to_tenant(&parent));
let root = TenantPath::single(12345678);
assert!(arn.belongs_to_tenant(&root));
let other = TenantPath::single(99999999);
assert!(!arn.belongs_to_tenant(&other));
let sibling = TenantPath::new(vec![12345678, 11111111]);
assert!(!arn.belongs_to_tenant(&sibling));
}
#[test]
fn test_resource() {
let resource = Resource::new("user", "77557755");
assert_eq!(resource.as_path(), "user/77557755");
assert_eq!(resource.to_string(), "user/77557755");
}
#[test]
fn test_service() {
assert_eq!(Service::Iam.as_str(), "iam");
assert_eq!(Service::Sts.as_str(), "sts");
assert_eq!(Service::SsoAdmin.as_str(), "sso-admin");
assert_eq!(Service::Custom("custom".to_string()).as_str(), "custom");
}
#[test]
fn test_service_from_str() {
assert_eq!(Service::from("iam"), Service::Iam);
assert_eq!(Service::from("sts"), Service::Sts);
assert_eq!(Service::from("sso-admin"), Service::SsoAdmin);
assert_eq!(
Service::from("custom"),
Service::Custom("custom".to_string())
);
}
#[test]
fn test_tenant_path_from_segments() {
let root_path = TenantPath::from_segments(vec![0]);
assert_eq!(root_path.segments, vec![0]);
assert_eq!(root_path.depth(), 1);
let child_path = TenantPath::from_segments(vec![0, 12345678]);
assert_eq!(child_path.segments, vec![0, 12345678]);
assert_eq!(child_path.depth(), 2);
let grandchild_path = TenantPath::from_segments(vec![0, 12345678, 87654321]);
assert_eq!(grandchild_path.segments, vec![0, 12345678, 87654321]);
assert_eq!(grandchild_path.depth(), 3);
}
#[test]
fn test_wami_arn_native() {
let arn = WamiArn {
service: Service::Iam,
tenant_path: TenantPath::new(vec![12345678, 87654321, 99999999]),
wami_instance_id: "999888777".to_string(),
cloud_mapping: None,
resource: Resource::new("user", "77557755"),
};
assert_eq!(
arn.to_string(),
"arn:wami:iam:12345678/87654321/99999999:wami:999888777:user/77557755"
);
assert_eq!(
arn.prefix(),
"arn:wami:iam:12345678/87654321/99999999:wami:999888777"
);
assert!(!arn.is_cloud_synced());
assert_eq!(arn.primary_tenant(), Some("12345678".to_string()));
assert_eq!(arn.leaf_tenant(), Some("99999999".to_string()));
assert_eq!(arn.full_tenant_path(), "12345678/87654321/99999999");
assert_eq!(arn.resource_type(), "user");
assert_eq!(arn.resource_id(), "77557755");
}
#[test]
fn test_wami_arn_cloud_synced() {
let arn = WamiArn {
service: Service::Iam,
tenant_path: TenantPath::new(vec![12345678, 87654321, 99999999]),
wami_instance_id: "999888777".to_string(),
cloud_mapping: Some(CloudMapping::new("aws", "223344556677")),
resource: Resource::new("user", "77557755"),
};
assert_eq!(
arn.to_string(),
"arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:global:user/77557755"
);
assert_eq!(
arn.prefix(),
"arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:global"
);
assert!(arn.is_cloud_synced());
assert_eq!(arn.provider(), Some("aws"));
}
#[test]
fn test_wami_arn_cloud_synced_with_region() {
let arn = WamiArn {
service: Service::Iam,
tenant_path: TenantPath::new(vec![12345678, 87654321, 99999999]),
wami_instance_id: "999888777".to_string(),
cloud_mapping: Some(CloudMapping::with_region(
"aws",
"223344556677",
"us-east-1",
)),
resource: Resource::new("user", "77557755"),
};
assert_eq!(
arn.to_string(),
"arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:us-east-1:user/77557755"
);
assert_eq!(
arn.prefix(),
"arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:us-east-1"
);
assert!(arn.is_cloud_synced());
assert!(arn.cloud_mapping.as_ref().unwrap().is_regional());
}
#[test]
fn test_matches_prefix() {
let arn = WamiArn {
service: Service::Iam,
tenant_path: TenantPath::new(vec![12345678, 87654321]),
wami_instance_id: "999888777".to_string(),
cloud_mapping: None,
resource: Resource::new("user", "77557755"),
};
assert!(arn.matches_prefix("arn:wami:iam:12345678/87654321:wami:999888777"));
assert!(arn.matches_prefix("arn:wami:iam:12345678/87654321"));
assert!(arn.matches_prefix("arn:wami"));
assert!(!arn.matches_prefix("arn:wami:sts"));
}
#[test]
fn test_belongs_to_tenant() {
let arn = WamiArn {
service: Service::Iam,
tenant_path: TenantPath::new(vec![12345678, 87654321, 99999999]),
wami_instance_id: "999888777".to_string(),
cloud_mapping: None,
resource: Resource::new("user", "77557755"),
};
let same = TenantPath::new(vec![12345678, 87654321, 99999999]);
let parent = TenantPath::new(vec![12345678, 87654321]);
let root = TenantPath::single(12345678);
let other = TenantPath::single(99999999);
assert!(arn.belongs_to_tenant(&same));
assert!(arn.belongs_to_tenant(&parent));
assert!(arn.belongs_to_tenant(&root));
assert!(!arn.belongs_to_tenant(&other));
}
#[test]
fn test_serialization() {
let arn = WamiArn {
service: Service::Iam,
tenant_path: TenantPath::single(12345678),
wami_instance_id: "999888777".to_string(),
cloud_mapping: None,
resource: Resource::new("user", "77557755"),
};
let json = serde_json::to_string(&arn).unwrap();
assert_eq!(
json,
"\"arn:wami:iam:12345678:wami:999888777:user/77557755\""
);
let deserialized: WamiArn = serde_json::from_str(&json).unwrap();
assert_eq!(arn, deserialized);
}
#[test]
fn test_serialization_cloud_synced() {
let arn = WamiArn {
service: Service::Iam,
tenant_path: TenantPath::single(12345678),
wami_instance_id: "999888777".to_string(),
cloud_mapping: Some(CloudMapping::with_region(
"aws",
"223344556677",
"us-east-1",
)),
resource: Resource::new("user", "77557755"),
};
let json = serde_json::to_string(&arn).unwrap();
assert_eq!(
json,
"\"arn:wami:iam:12345678:wami:999888777:aws:223344556677:us-east-1:user/77557755\""
);
let deserialized: WamiArn = serde_json::from_str(&json).unwrap();
assert_eq!(arn, deserialized);
}
}