use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DomainScope {
#[default]
All,
Allowed { domains: Vec<String> },
AllowedWithDefault {
domains: Vec<String>,
default: String,
},
}
impl DomainScope {
pub fn new_allowed_with_default(domains: Vec<String>, default: String) -> Result<Self, String> {
if domains.is_empty() {
return Err("AllowedWithDefault requires a non-empty domain list".into());
}
if !domains.iter().any(|d| d == &default) {
return Err(format!(
"default '{default}' is not a member of allowed list {domains:?}"
));
}
Ok(Self::AllowedWithDefault { domains, default })
}
pub fn allows(&self, domain: &str) -> bool {
match self {
Self::All => true,
Self::Allowed { domains } => domains.iter().any(|d| d == domain),
Self::AllowedWithDefault { domains, .. } => domains.iter().any(|d| d == domain),
}
}
pub fn default_domain(&self) -> Option<&str> {
match self {
Self::AllowedWithDefault { default, .. } => Some(default),
_ => None,
}
}
}
pub fn resolve_request_domain(
request_domain: Option<&str>,
caller_scope: &DomainScope,
system_default: Option<&str>,
) -> Result<String, DomainResolveError> {
if let Some(d) = request_domain
&& !d.is_empty()
{
return Ok(d.to_string());
}
if let Some(d) = caller_scope.default_domain() {
return Ok(d.to_string());
}
if matches!(caller_scope, DomainScope::All)
&& let Some(d) = system_default
{
return Ok(d.to_string());
}
Err(DomainResolveError::NoDefault)
}
#[derive(Debug, PartialEq, Eq)]
pub enum DomainResolveError {
NoDefault,
}
impl std::fmt::Display for DomainResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoDefault => write!(
f,
"request omits `domain` and the caller's ACL scope has no default; \
explicit `domain` is required for this caller"
),
}
}
}
#[cfg(test)]
mod resolve_tests {
use super::*;
fn allowed(names: &[&str]) -> DomainScope {
DomainScope::Allowed {
domains: names.iter().map(|s| s.to_string()).collect(),
}
}
fn with_default(names: &[&str], default: &str) -> DomainScope {
DomainScope::AllowedWithDefault {
domains: names.iter().map(|s| s.to_string()).collect(),
default: default.to_string(),
}
}
#[test]
fn explicit_request_domain_wins() {
let r = resolve_request_domain(
Some("explicit.example"),
&with_default(&["a.example", "b.example"], "a.example"),
Some("system.example"),
);
assert_eq!(r.unwrap(), "explicit.example");
}
#[test]
fn empty_request_domain_falls_through_to_default() {
let r = resolve_request_domain(
Some(""),
&with_default(&["a.example"], "a.example"),
Some("system.example"),
);
assert_eq!(r.unwrap(), "a.example");
}
#[test]
fn acl_default_used_when_request_omits() {
let r = resolve_request_domain(
None,
&with_default(&["a.example", "b.example"], "b.example"),
Some("system.example"),
);
assert_eq!(r.unwrap(), "b.example");
}
#[test]
fn all_scope_falls_back_to_system_default() {
let r = resolve_request_domain(None, &DomainScope::All, Some("system.example"));
assert_eq!(r.unwrap(), "system.example");
}
#[test]
fn allowed_without_default_rejects_implicit() {
let err = resolve_request_domain(
None,
&allowed(&["a.example", "b.example"]),
Some("system.example"),
)
.expect_err("must require explicit domain");
assert_eq!(err, DomainResolveError::NoDefault);
}
#[test]
fn all_scope_with_no_system_default_rejects() {
let err = resolve_request_domain(None, &DomainScope::All, None)
.expect_err("no source of truth — must reject");
assert_eq!(err, DomainResolveError::NoDefault);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all() {
assert!(matches!(DomainScope::default(), DomainScope::All));
}
#[test]
fn all_allows_everything() {
assert!(DomainScope::All.allows("any-domain.example"));
assert!(DomainScope::All.allows("another.example"));
}
#[test]
fn allowed_gates_by_membership() {
let scope = DomainScope::Allowed {
domains: vec!["a.example".into(), "b.example".into()],
};
assert!(scope.allows("a.example"));
assert!(scope.allows("b.example"));
assert!(!scope.allows("c.example"));
}
#[test]
fn allowed_with_default_validates_default_membership() {
let ok = DomainScope::new_allowed_with_default(
vec!["a.example".into(), "b.example".into()],
"a.example".into(),
)
.expect("default in list");
assert_eq!(ok.default_domain(), Some("a.example"));
let err =
DomainScope::new_allowed_with_default(vec!["a.example".into()], "b.example".into())
.expect_err("default not in list must reject");
assert!(err.contains("not a member"));
}
#[test]
fn allowed_with_default_rejects_empty_list() {
let err = DomainScope::new_allowed_with_default(vec![], "a.example".into())
.expect_err("empty list must reject");
assert!(err.contains("non-empty"));
}
#[test]
fn default_domain_only_on_allowed_with_default() {
assert_eq!(DomainScope::All.default_domain(), None);
assert_eq!(
DomainScope::Allowed {
domains: vec!["x".into()]
}
.default_domain(),
None
);
let scoped = DomainScope::new_allowed_with_default(vec!["x".into()], "x".into()).unwrap();
assert_eq!(scoped.default_domain(), Some("x"));
}
#[test]
fn round_trips_all_variant() {
let scope = DomainScope::All;
let json = serde_json::to_string(&scope).unwrap();
assert_eq!(json, r#"{"kind":"all"}"#);
let back: DomainScope = serde_json::from_str(&json).unwrap();
assert_eq!(scope, back);
}
#[test]
fn round_trips_allowed_variant() {
let scope = DomainScope::Allowed {
domains: vec!["a".into(), "b".into()],
};
let json = serde_json::to_string(&scope).unwrap();
let back: DomainScope = serde_json::from_str(&json).unwrap();
assert_eq!(scope, back);
}
#[test]
fn round_trips_allowed_with_default_variant() {
let scope = DomainScope::AllowedWithDefault {
domains: vec!["a".into(), "b".into()],
default: "a".into(),
};
let json = serde_json::to_string(&scope).unwrap();
let back: DomainScope = serde_json::from_str(&json).unwrap();
assert_eq!(scope, back);
}
#[test]
fn snake_case_tag_in_wire_form() {
let scope = DomainScope::AllowedWithDefault {
domains: vec!["a".into()],
default: "a".into(),
};
let json = serde_json::to_string(&scope).unwrap();
assert!(
json.contains("\"kind\":\"allowed_with_default\""),
"expected snake_case tag, got {json}"
);
}
}