pub mod arn_builder;
pub mod provider_info;
pub mod registry;
pub mod aws;
pub mod azure;
pub mod custom;
pub mod gcp;
pub use registry::ProviderRegistry;
pub use aws::AwsProvider;
pub use azure::AzureProvider;
pub use custom::CustomProvider;
pub use gcp::GcpProvider;
pub use provider_info::ProviderInfo;
use serde::{Deserialize, Serialize};
use wami_core::error::{AmiError, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProviderConfig {
pub provider_name: String,
pub account_id: String,
pub native_arn: String,
pub synced_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceType {
User,
Group,
Role,
Policy,
AccessKey,
ServerCertificate,
ServiceCredential,
ServiceLinkedRole,
MfaDevice,
SigningCertificate,
SamlProvider,
OidcProvider,
StsAssumedRole,
StsFederatedUser,
StsSession,
Tenant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceLimits {
pub max_access_keys_per_user: usize,
pub max_signing_certificates_per_user: usize,
pub max_service_credentials_per_user_per_service: usize,
pub max_tags_per_resource: usize,
pub max_mfa_devices_per_user: usize,
pub session_duration_min: i32,
pub session_duration_max: i32,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
max_access_keys_per_user: 2,
max_signing_certificates_per_user: 2,
max_service_credentials_per_user_per_service: 2,
max_tags_per_resource: 50,
max_mfa_devices_per_user: 8,
session_duration_min: 3600, session_duration_max: 43200, }
}
}
pub trait CloudProvider: Send + Sync + std::fmt::Debug {
fn name(&self) -> &str;
fn generate_resource_identifier(
&self,
resource_type: ResourceType,
account_id: &str,
path: &str,
name: &str,
) -> String;
fn generate_resource_id(&self, resource_type: ResourceType) -> String;
fn resource_limits(&self) -> &ResourceLimits;
#[allow(clippy::result_large_err)]
fn validate_service_name(&self, service: &str) -> Result<()>;
#[allow(clippy::result_large_err)]
fn validate_path(&self, path: &str) -> Result<()>;
#[allow(clippy::result_large_err)]
fn validate_session_duration(&self, duration: i32) -> Result<()> {
let limits = self.resource_limits();
if duration < limits.session_duration_min || duration > limits.session_duration_max {
return Err(AmiError::InvalidParameter {
message: format!(
"Session duration must be between {} and {} seconds",
limits.session_duration_min, limits.session_duration_max
),
});
}
Ok(())
}
fn generate_service_linked_role_name(
&self,
service_name: &str,
custom_suffix: Option<&str>,
) -> String;
fn generate_service_linked_role_path(&self, service_name: &str) -> String;
fn generate_wami_arn(
&self,
resource_type: ResourceType,
account_id: &str,
path: &str,
name: &str,
) -> String {
let service = match resource_type {
ResourceType::User
| ResourceType::Group
| ResourceType::Role
| ResourceType::Policy
| ResourceType::AccessKey
| ResourceType::MfaDevice
| ResourceType::ServiceLinkedRole
| ResourceType::ServiceCredential
| ResourceType::SigningCertificate
| ResourceType::ServerCertificate
| ResourceType::SamlProvider
| ResourceType::OidcProvider => "iam",
ResourceType::StsAssumedRole
| ResourceType::StsFederatedUser
| ResourceType::StsSession => "sts",
ResourceType::Tenant => "organizations",
};
let resource_prefix = match resource_type {
ResourceType::User => "user",
ResourceType::Group => "group",
ResourceType::Role => "role",
ResourceType::Policy => "policy",
ResourceType::ServerCertificate => "server-certificate",
ResourceType::AccessKey => "access-key",
ResourceType::ServiceCredential => "service-credential",
ResourceType::ServiceLinkedRole => "role",
ResourceType::MfaDevice => "mfa",
ResourceType::SigningCertificate => "signing-certificate",
ResourceType::SamlProvider => "saml-provider",
ResourceType::OidcProvider => "oidc-provider",
ResourceType::StsAssumedRole => "assumed-role",
ResourceType::StsFederatedUser => "federated-user",
ResourceType::StsSession => "session",
ResourceType::Tenant => "ou",
};
let normalized_path = if path.is_empty() || path == "/" {
String::new()
} else {
let trimmed = path.trim();
if trimmed.is_empty() || trimmed == "/" {
String::new()
} else {
let mut p = trimmed.to_string();
if !p.starts_with('/') {
p.insert(0, '/');
}
if !p.ends_with('/') {
p.push('/');
}
p[1..].to_string()
}
};
if normalized_path.is_empty() {
format!(
"arn:wami:{}::{}:{}/{}",
service, account_id, resource_prefix, name
)
} else {
format!(
"arn:wami:{}::{}:{}/{}{}",
service, account_id, resource_prefix, normalized_path, name
)
}
}
}
impl dyn CloudProvider {
pub fn tenant_aware_path(tenant_id: Option<&str>, base_path: &str) -> String {
match tenant_id {
Some(tid) if !tid.is_empty() => {
let normalized_base = base_path.trim_end_matches('/');
format!("{}/tenants/{}/", normalized_base, tid)
}
_ => base_path.to_string(),
}
}
pub fn extract_tenant_from_path(path: &str) -> Option<String> {
if let Some(tenant_start) = path.find("/tenants/") {
let tenant_part = &path[tenant_start + "/tenants/".len()..];
let segments: Vec<&str> = tenant_part.split('/').filter(|s| !s.is_empty()).collect();
if !segments.is_empty() {
return Some(segments.join("/"));
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::aws::AwsProvider;
fn provider() -> AwsProvider {
AwsProvider::default()
}
#[test]
fn every_resource_type_maps_to_a_service_and_a_prefix() {
let p = provider();
let cases = [
(ResourceType::User, "iam", "user"),
(ResourceType::Group, "iam", "group"),
(ResourceType::Role, "iam", "role"),
(ResourceType::Policy, "iam", "policy"),
(ResourceType::AccessKey, "iam", "access-key"),
(ResourceType::MfaDevice, "iam", "mfa"),
(ResourceType::ServiceLinkedRole, "iam", "role"),
(ResourceType::ServiceCredential, "iam", "service-credential"),
(
ResourceType::SigningCertificate,
"iam",
"signing-certificate",
),
(ResourceType::ServerCertificate, "iam", "server-certificate"),
(ResourceType::SamlProvider, "iam", "saml-provider"),
(ResourceType::OidcProvider, "iam", "oidc-provider"),
(ResourceType::StsAssumedRole, "sts", "assumed-role"),
(ResourceType::StsFederatedUser, "sts", "federated-user"),
(ResourceType::StsSession, "sts", "session"),
(ResourceType::Tenant, "organizations", "ou"),
];
for (ty, service, prefix) in cases {
let arn = p.generate_wami_arn(ty, "123456789012", "/", "thing");
assert_eq!(
arn,
format!("arn:wami:{service}::123456789012:{prefix}/thing"),
"{ty:?} landed in the wrong place"
);
}
}
#[test]
fn a_service_linked_role_is_a_role_but_a_session_is_not_an_identity() {
let p = provider();
assert_eq!(
p.generate_wami_arn(ResourceType::ServiceLinkedRole, "1", "/", "n"),
p.generate_wami_arn(ResourceType::Role, "1", "/", "n"),
"a service-linked role is addressed as a role"
);
assert!(p
.generate_wami_arn(ResourceType::StsSession, "1", "/", "n")
.starts_with("arn:wami:sts::"));
}
#[test]
fn a_path_reaches_the_arn_in_one_shape_however_it_was_written() {
let p = provider();
let expected = "arn:wami:iam::123456789012:user/engineering/alice";
for spelling in [
"/engineering/",
"engineering",
"/engineering",
"engineering/",
] {
assert_eq!(
p.generate_wami_arn(ResourceType::User, "123456789012", spelling, "alice"),
expected,
"{spelling:?} produced a different ARN"
);
}
}
#[test]
fn an_empty_path_leaves_no_trace() {
let p = provider();
for empty in ["", "/", " "] {
assert_eq!(
p.generate_wami_arn(ResourceType::User, "123456789012", empty, "alice"),
"arn:wami:iam::123456789012:user/alice",
"{empty:?} left something behind"
);
}
}
#[test]
fn a_nested_path_keeps_its_depth() {
let p = provider();
assert_eq!(
p.generate_wami_arn(ResourceType::Role, "1", "/eng/platform/", "deployer"),
"arn:wami:iam::1:role/eng/platform/deployer"
);
}
#[test]
fn a_session_duration_is_checked_against_the_providers_own_limits() {
let p = provider();
let limits = p.resource_limits();
assert!(p
.validate_session_duration(limits.session_duration_min)
.is_ok());
assert!(p
.validate_session_duration(limits.session_duration_max)
.is_ok());
assert!(p
.validate_session_duration(limits.session_duration_min - 1)
.is_err());
assert!(p
.validate_session_duration(limits.session_duration_max + 1)
.is_err());
}
#[test]
fn a_tenant_path_round_trips() {
let path = <dyn CloudProvider>::tenant_aware_path(Some("acme/engineering"), "/");
assert_eq!(path, "/tenants/acme/engineering/");
assert_eq!(
<dyn CloudProvider>::extract_tenant_from_path(&path),
Some("acme/engineering".to_string())
);
}
#[test]
fn a_path_without_a_tenant_is_left_alone_and_yields_none() {
assert_eq!(
<dyn CloudProvider>::tenant_aware_path(None, "/admin/"),
"/admin/"
);
assert_eq!(
<dyn CloudProvider>::tenant_aware_path(Some(""), "/admin/"),
"/admin/"
);
assert_eq!(
<dyn CloudProvider>::extract_tenant_from_path("/admin/"),
None
);
assert_eq!(
<dyn CloudProvider>::extract_tenant_from_path("/tenants/"),
None,
"the marker alone names no tenant"
);
}
}