use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
use crate::metrics::record_secret_residency_check;
use crate::playbook::types::KeychainDef;
use crate::secrets::server_region;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Residency {
#[default]
None,
Advisory,
Strict,
}
impl Residency {
fn as_label(self) -> &'static str {
match self {
Residency::None => "none",
Residency::Advisory => "advisory",
Residency::Strict => "strict",
}
}
}
#[derive(Debug)]
pub enum ResidencyDecision {
Allow(&'static str),
AllowWithViolationLogged,
Deny(AppError),
}
pub fn evaluate(kc: &KeychainDef, entry_region: &str) -> ResidencyDecision {
let policy = kc.residency;
let server_region = server_region();
if entry_region.is_empty() {
let decision = ResidencyDecision::Allow("allowed_no_policy");
record_secret_residency_check(policy.as_label(), label_of(&decision));
return decision;
}
let decision = match policy {
Residency::None => ResidencyDecision::Allow("allowed_no_policy"),
Residency::Advisory | Residency::Strict => {
if entry_region == server_region {
ResidencyDecision::Allow("allowed_same_region")
} else if kc
.allowed_regions
.iter()
.any(|r| r.as_str() == server_region && !server_region.is_empty())
{
ResidencyDecision::Allow("allowed_in_allowlist")
} else {
match policy {
Residency::Strict => ResidencyDecision::Deny(AppError::ResidencyViolation {
credential: kc.name.clone(),
entry_region: entry_region.to_string(),
server_region: server_region.to_string(),
}),
_ => ResidencyDecision::AllowWithViolationLogged,
}
}
}
};
record_secret_residency_check(policy.as_label(), label_of(&decision));
decision
}
fn label_of(d: &ResidencyDecision) -> &'static str {
match d {
ResidencyDecision::Allow(l) => l,
ResidencyDecision::AllowWithViolationLogged => "violation_allowed",
ResidencyDecision::Deny(_) => "violation_blocked",
}
}
pub fn to_result(d: ResidencyDecision) -> AppResult<()> {
match d {
ResidencyDecision::Allow(_) | ResidencyDecision::AllowWithViolationLogged => Ok(()),
ResidencyDecision::Deny(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kc(name: &str, region: Option<&str>, residency: Residency, allowed: &[&str]) -> KeychainDef {
KeychainDef {
name: name.to_string(),
credential: None,
token_type: None,
scope: None,
provider: None,
auth: None,
map: None,
region: region.map(|s| s.to_string()),
residency,
allowed_regions: allowed.iter().map(|s| s.to_string()).collect(),
no_broker_fallback: false,
auto_renew: false,
extra: Default::default(),
}
}
#[test]
fn none_policy_allows_everything() {
let entry = kc("eu_token", Some("eu-central-1"), Residency::None, &[]);
let d = evaluate(&entry, "eu-central-1");
assert!(matches!(d, ResidencyDecision::Allow("allowed_no_policy")));
let d = evaluate(&entry, "us-east-1");
assert!(matches!(d, ResidencyDecision::Allow("allowed_no_policy")));
}
#[test]
fn strict_same_region_allows() {
let entry = kc("local", Some(server_region()), Residency::Strict, &[]);
if server_region().is_empty() {
let d = evaluate(&entry, server_region());
assert!(matches!(d, ResidencyDecision::Allow("allowed_no_policy")));
} else {
let d = evaluate(&entry, server_region());
assert!(matches!(d, ResidencyDecision::Allow("allowed_same_region")));
}
}
#[test]
fn strict_mismatch_denies_with_residency_violation() {
let entry = kc("eu_token", Some("eu-central-1"), Residency::Strict, &[]);
let d = evaluate(&entry, "eu-central-1");
if server_region() != "eu-central-1" {
match d {
ResidencyDecision::Deny(AppError::ResidencyViolation {
credential,
entry_region,
server_region: srv,
}) => {
assert_eq!(credential, "eu_token");
assert_eq!(entry_region, "eu-central-1");
assert_eq!(srv, server_region());
}
other => panic!("expected ResidencyViolation, got {other:?}"),
}
}
}
#[test]
fn strict_allowlist_hit_allows_when_server_region_matches() {
let srv = server_region();
if srv.is_empty() {
return;
}
let entry = kc("eu_token", Some("eu-central-1"), Residency::Strict, &[srv]);
let d = evaluate(&entry, "eu-central-1");
assert!(matches!(
d,
ResidencyDecision::Allow("allowed_in_allowlist")
));
}
#[test]
fn advisory_mismatch_allows_and_records_violation() {
let entry = kc("eu_token", Some("eu-central-1"), Residency::Advisory, &[]);
if server_region() != "eu-central-1" {
let d = evaluate(&entry, "eu-central-1");
assert!(matches!(d, ResidencyDecision::AllowWithViolationLogged));
assert!(to_result(d).is_ok());
}
}
#[test]
fn empty_entry_region_short_circuits_to_allow_no_policy() {
let entry = kc("legacy", None, Residency::Strict, &[]);
let d = evaluate(&entry, "");
assert!(matches!(d, ResidencyDecision::Allow("allowed_no_policy")));
}
#[test]
fn empty_allowlist_entry_does_not_falsely_match_empty_server_region() {
let entry = kc("eu_token", Some("eu-central-1"), Residency::Strict, &[""]);
if server_region().is_empty() {
let d = evaluate(&entry, "eu-central-1");
assert!(
matches!(d, ResidencyDecision::Deny(_)),
"empty string in allowlist must not match empty server region"
);
}
}
#[test]
fn to_result_propagates_deny() {
let err = AppError::ResidencyViolation {
credential: "c".to_string(),
entry_region: "eu".to_string(),
server_region: "us".to_string(),
};
let r = to_result(ResidencyDecision::Deny(err));
match r {
Err(AppError::ResidencyViolation { .. }) => {}
other => panic!("expected ResidencyViolation, got {other:?}"),
}
}
}