use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DataClassification {
Public,
Internal,
Confidential,
Restricted,
}
impl DataClassification {
pub fn sensitivity(&self) -> u8 {
match self {
DataClassification::Public => 0,
DataClassification::Internal => 1,
DataClassification::Confidential => 2,
DataClassification::Restricted => 3,
}
}
}
impl std::fmt::Display for DataClassification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DataClassification::Public => write!(f, "Public"),
DataClassification::Internal => write!(f, "Internal"),
DataClassification::Confidential => write!(f, "Confidential"),
DataClassification::Restricted => write!(f, "Restricted"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionRule {
pub tenant_id: Option<String>,
pub data_classification: DataClassification,
pub allowed_regions: Vec<String>,
pub allowed_providers: Vec<String>,
pub blocked_providers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutingDecision {
pub allowed: bool,
pub provider: Option<String>,
pub region: String,
pub reason: String,
}
pub struct RegionRouter {
rules: Vec<RegionRule>,
default_region: String,
}
impl RegionRouter {
pub fn new(default_region: impl Into<String>) -> Self {
Self {
rules: Vec::new(),
default_region: default_region.into(),
}
}
pub fn add_rule(&mut self, rule: RegionRule) {
self.rules.push(rule);
}
pub fn route(
&self,
tenant_id: Option<&str>,
classification: &DataClassification,
preferred_provider: &str,
) -> RoutingDecision {
let mut tenant_rules: Vec<&RegionRule> = Vec::new();
let mut global_rules: Vec<&RegionRule> = Vec::new();
for rule in &self.rules {
if rule.data_classification != *classification {
continue;
}
match (&rule.tenant_id, tenant_id) {
(Some(rule_tid), Some(req_tid)) if rule_tid == req_tid => {
tenant_rules.push(rule);
}
(None, _) => {
global_rules.push(rule);
}
_ => {}
}
}
let applicable = if !tenant_rules.is_empty() {
tenant_rules
} else {
global_rules
};
if applicable.is_empty() {
return RoutingDecision {
allowed: true,
provider: Some(preferred_provider.to_string()),
region: self.default_region.clone(),
reason: format!(
"No rules matched for classification={classification}; using defaults"
),
};
}
let rule = applicable[0];
let provider_lower = preferred_provider.to_lowercase();
if rule
.blocked_providers
.iter()
.any(|p| p.to_lowercase() == provider_lower)
{
let alternative = rule
.allowed_providers
.iter()
.find(|p| {
!rule
.blocked_providers
.iter()
.any(|b| b.to_lowercase() == p.to_lowercase())
})
.cloned();
if let Some(alt) = alternative {
let region = rule
.allowed_regions
.first()
.cloned()
.unwrap_or_else(|| self.default_region.clone());
return RoutingDecision {
allowed: true,
provider: Some(alt.clone()),
region,
reason: format!(
"Provider '{preferred_provider}' blocked for {classification} data; \
rerouted to '{alt}'"
),
};
}
return RoutingDecision {
allowed: false,
provider: None,
region: rule
.allowed_regions
.first()
.cloned()
.unwrap_or_else(|| self.default_region.clone()),
reason: format!(
"Provider '{preferred_provider}' blocked for {classification} data \
and no alternative providers available"
),
};
}
if !rule.allowed_providers.is_empty()
&& !rule
.allowed_providers
.iter()
.any(|p| p.to_lowercase() == provider_lower)
{
let suggestion = rule.allowed_providers.first().cloned();
if let Some(ref alt) = suggestion {
let region = rule
.allowed_regions
.first()
.cloned()
.unwrap_or_else(|| self.default_region.clone());
return RoutingDecision {
allowed: true,
provider: Some(alt.clone()),
region,
reason: format!(
"Provider '{preferred_provider}' not in allowlist for {classification} data; \
rerouted to '{alt}'"
),
};
}
return RoutingDecision {
allowed: false,
provider: None,
region: self.default_region.clone(),
reason: format!(
"Provider '{preferred_provider}' not in allowlist for {classification} data"
),
};
}
let region = rule
.allowed_regions
.first()
.cloned()
.unwrap_or_else(|| self.default_region.clone());
RoutingDecision {
allowed: true,
provider: Some(preferred_provider.to_string()),
region,
reason: format!(
"Allowed: provider='{preferred_provider}', classification={classification}"
),
}
}
pub fn allowed_providers(
&self,
tenant_id: Option<&str>,
classification: &DataClassification,
) -> Vec<String> {
let mut tenant_rules: Vec<&RegionRule> = Vec::new();
let mut global_rules: Vec<&RegionRule> = Vec::new();
for rule in &self.rules {
if rule.data_classification != *classification {
continue;
}
match (&rule.tenant_id, tenant_id) {
(Some(rule_tid), Some(req_tid)) if rule_tid == req_tid => {
tenant_rules.push(rule);
}
(None, _) => {
global_rules.push(rule);
}
_ => {}
}
}
let applicable = if !tenant_rules.is_empty() {
tenant_rules
} else {
global_rules
};
if applicable.is_empty() {
return Vec::new();
}
let mut allowed: Vec<String> = Vec::new();
let mut blocked: Vec<String> = Vec::new();
for rule in &applicable {
for p in &rule.allowed_providers {
if !allowed.iter().any(|a| a.to_lowercase() == p.to_lowercase()) {
allowed.push(p.clone());
}
}
for p in &rule.blocked_providers {
if !blocked.iter().any(|b| b.to_lowercase() == p.to_lowercase()) {
blocked.push(p.clone());
}
}
}
allowed.retain(|a| !blocked.iter().any(|b| b.to_lowercase() == a.to_lowercase()));
allowed
}
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
for (i, rule) in self.rules.iter().enumerate() {
let label = match &rule.tenant_id {
Some(tid) => format!(
"Rule #{} (tenant={}, class={})",
i + 1,
tid,
rule.data_classification
),
None => format!(
"Rule #{} (global, class={})",
i + 1,
rule.data_classification
),
};
if rule.allowed_regions.is_empty() {
issues.push(format!("{label}: allowed_regions is empty"));
}
for p in &rule.allowed_providers {
if rule
.blocked_providers
.iter()
.any(|b| b.to_lowercase() == p.to_lowercase())
{
issues.push(format!(
"{label}: provider '{p}' appears in both allowed and blocked lists"
));
}
}
}
for i in 0..self.rules.len() {
for j in (i + 1)..self.rules.len() {
let a = &self.rules[i];
let b = &self.rules[j];
if a.tenant_id == b.tenant_id && a.data_classification == b.data_classification {
let label = match &a.tenant_id {
Some(tid) => format!("tenant={tid}, class={}", a.data_classification),
None => format!("global, class={}", a.data_classification),
};
issues.push(format!(
"Duplicate rules #{} and #{} for ({label})",
i + 1,
j + 1
));
}
}
}
issues
}
pub fn default_region(&self) -> &str {
&self.default_region
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn eu_restricted_rule() -> RegionRule {
RegionRule {
tenant_id: None,
data_classification: DataClassification::Restricted,
allowed_regions: vec!["eu-west-1".into(), "eu-central-1".into()],
allowed_providers: vec!["claude".into(), "gemini".into()],
blocked_providers: vec!["openai".into()],
}
}
fn public_any_rule() -> RegionRule {
RegionRule {
tenant_id: None,
data_classification: DataClassification::Public,
allowed_regions: vec!["us-east-1".into(), "eu-west-1".into()],
allowed_providers: vec![],
blocked_providers: vec![],
}
}
fn tenant_acme_confidential() -> RegionRule {
RegionRule {
tenant_id: Some("acme-corp".into()),
data_classification: DataClassification::Confidential,
allowed_regions: vec!["eu-west-1".into()],
allowed_providers: vec!["claude".into()],
blocked_providers: vec![],
}
}
#[test]
fn test_default_routing_no_rules() {
let router = RegionRouter::new("us-east-1");
let decision = router.route(None, &DataClassification::Public, "openai");
assert!(decision.allowed);
assert_eq!(decision.provider, Some("openai".to_string()));
assert_eq!(decision.region, "us-east-1");
}
#[test]
fn test_restricted_data_blocks_openai() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(eu_restricted_rule());
let decision = router.route(None, &DataClassification::Restricted, "openai");
assert!(decision.allowed);
assert_eq!(decision.provider, Some("claude".to_string()));
assert_eq!(decision.region, "eu-west-1");
assert!(decision.reason.contains("blocked"));
}
#[test]
fn test_restricted_data_allows_claude() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(eu_restricted_rule());
let decision = router.route(None, &DataClassification::Restricted, "claude");
assert!(decision.allowed);
assert_eq!(decision.provider, Some("claude".to_string()));
assert_eq!(decision.region, "eu-west-1");
}
#[test]
fn test_public_data_any_provider() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(public_any_rule());
let decision = router.route(None, &DataClassification::Public, "openai");
assert!(decision.allowed);
assert_eq!(decision.provider, Some("openai".to_string()));
assert_eq!(decision.region, "us-east-1");
}
#[test]
fn test_tenant_specific_overrides_global() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Confidential,
allowed_regions: vec!["us-east-1".into()],
allowed_providers: vec![],
blocked_providers: vec![],
});
router.add_rule(tenant_acme_confidential());
let d1 = router.route(
Some("other-corp"),
&DataClassification::Confidential,
"openai",
);
assert!(d1.allowed);
assert_eq!(d1.provider, Some("openai".to_string()));
assert_eq!(d1.region, "us-east-1");
let d2 = router.route(
Some("acme-corp"),
&DataClassification::Confidential,
"openai",
);
assert!(d2.allowed);
assert_eq!(d2.provider, Some("claude".to_string()));
assert_eq!(d2.region, "eu-west-1");
}
#[test]
fn test_classification_levels_independent() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(eu_restricted_rule());
let decision = router.route(None, &DataClassification::Internal, "openai");
assert!(decision.allowed);
assert_eq!(decision.provider, Some("openai".to_string()));
assert_eq!(decision.region, "us-east-1");
}
#[test]
fn test_blocked_no_alternatives_denied() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Restricted,
allowed_regions: vec!["eu-west-1".into()],
allowed_providers: vec![],
blocked_providers: vec!["openai".into()],
});
let decision = router.route(None, &DataClassification::Restricted, "openai");
assert!(!decision.allowed);
assert!(decision.provider.is_none());
assert!(decision.reason.contains("blocked"));
}
#[test]
fn test_validate_empty_regions() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Public,
allowed_regions: vec![],
allowed_providers: vec![],
blocked_providers: vec![],
});
let issues = router.validate();
assert!(!issues.is_empty());
assert!(issues[0].contains("allowed_regions is empty"));
}
#[test]
fn test_validate_provider_in_both_lists() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Internal,
allowed_regions: vec!["us-east-1".into()],
allowed_providers: vec!["openai".into()],
blocked_providers: vec!["openai".into()],
});
let issues = router.validate();
assert!(issues
.iter()
.any(|i| i.contains("both allowed and blocked")));
}
#[test]
fn test_validate_duplicate_rules() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Public,
allowed_regions: vec!["us-east-1".into()],
allowed_providers: vec![],
blocked_providers: vec![],
});
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Public,
allowed_regions: vec!["eu-west-1".into()],
allowed_providers: vec![],
blocked_providers: vec![],
});
let issues = router.validate();
assert!(issues.iter().any(|i| i.contains("Duplicate rules")));
}
#[test]
fn test_allowed_providers_list() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(eu_restricted_rule());
let providers = router.allowed_providers(None, &DataClassification::Restricted);
assert_eq!(providers.len(), 2);
assert!(providers.contains(&"claude".to_string()));
assert!(providers.contains(&"gemini".to_string()));
}
#[test]
fn test_allowed_providers_empty_no_match() {
let router = RegionRouter::new("us-east-1");
let providers = router.allowed_providers(None, &DataClassification::Public);
assert!(providers.is_empty());
}
#[test]
fn test_data_classification_display() {
assert_eq!(DataClassification::Public.to_string(), "Public");
assert_eq!(DataClassification::Internal.to_string(), "Internal");
assert_eq!(DataClassification::Confidential.to_string(), "Confidential");
assert_eq!(DataClassification::Restricted.to_string(), "Restricted");
}
#[test]
fn test_data_classification_sensitivity() {
assert!(
DataClassification::Public.sensitivity() < DataClassification::Internal.sensitivity()
);
assert!(
DataClassification::Internal.sensitivity()
< DataClassification::Confidential.sensitivity()
);
assert!(
DataClassification::Confidential.sensitivity()
< DataClassification::Restricted.sensitivity()
);
}
#[test]
fn test_multiple_classification_rules() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(eu_restricted_rule());
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Confidential,
allowed_regions: vec!["us-east-1".into(), "eu-west-1".into()],
allowed_providers: vec!["claude".into(), "openai".into()],
blocked_providers: vec![],
});
let d1 = router.route(None, &DataClassification::Restricted, "openai");
assert!(d1.allowed);
assert_eq!(d1.provider, Some("claude".to_string()));
let d2 = router.route(None, &DataClassification::Confidential, "openai");
assert!(d2.allowed);
assert_eq!(d2.provider, Some("openai".to_string()));
}
#[test]
fn test_provider_not_in_allowlist_rerouted() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(RegionRule {
tenant_id: None,
data_classification: DataClassification::Internal,
allowed_regions: vec!["eu-west-1".into()],
allowed_providers: vec!["claude".into(), "gemini".into()],
blocked_providers: vec![],
});
let decision = router.route(None, &DataClassification::Internal, "mistral");
assert!(decision.allowed);
assert_eq!(decision.provider, Some("claude".to_string()));
assert!(decision.reason.contains("not in allowlist"));
}
#[test]
fn test_serialization_roundtrip() {
let rule = eu_restricted_rule();
let json = serde_json::to_string(&rule).unwrap();
let deserialized: RegionRule = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.data_classification,
DataClassification::Restricted
);
assert_eq!(deserialized.allowed_regions.len(), 2);
assert_eq!(deserialized.blocked_providers.len(), 1);
}
#[test]
fn test_routing_decision_serialization() {
let decision = RoutingDecision {
allowed: true,
provider: Some("claude".to_string()),
region: "eu-west-1".to_string(),
reason: "Allowed by rule".to_string(),
};
let json = serde_json::to_string(&decision).unwrap();
let d: RoutingDecision = serde_json::from_str(&json).unwrap();
assert!(d.allowed);
assert_eq!(d.provider.unwrap(), "claude");
}
#[test]
fn test_valid_config_no_issues() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(eu_restricted_rule());
router.add_rule(public_any_rule());
let issues = router.validate();
assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
}
#[test]
fn test_case_insensitive_provider() {
let mut router = RegionRouter::new("us-east-1");
router.add_rule(eu_restricted_rule());
let decision = router.route(None, &DataClassification::Restricted, "OpenAI");
assert!(decision.allowed);
assert_eq!(decision.provider, Some("claude".to_string()));
assert!(decision.reason.contains("blocked"));
}
}