use crate::{
cache::{CacheOutcome, DestinationCache},
client::{DenyReason, EcpdsClient, EcpdsError},
config::EcpdsConfig,
};
use std::collections::HashMap;
use tracing::debug;
#[derive(Debug)]
pub struct AccessCheckResult {
pub cache_outcome: Option<CacheOutcome>,
pub result: Result<(), EcpdsError>,
}
pub struct EcpdsChecker {
client: EcpdsClient,
pub(crate) cache: DestinationCache,
match_key: String,
}
impl EcpdsChecker {
pub fn new(config: &EcpdsConfig) -> Result<Self, EcpdsError> {
Ok(Self {
client: EcpdsClient::new(config)?,
cache: DestinationCache::new(config.cache_ttl_seconds, config.max_entries),
match_key: config.match_key.clone(),
})
}
pub async fn check_access(
&self,
username: &str,
identifier: &HashMap<String, String>,
) -> AccessCheckResult {
let Some(destination) = identifier.get(&self.match_key) else {
return AccessCheckResult {
cache_outcome: None,
result: Err(EcpdsError::AccessDenied {
reason: DenyReason::MatchKeyMissing,
message: format!(
"Required field '{}' not found in request identifiers",
self.match_key
),
}),
};
};
let client = &self.client;
let (cache_outcome, fetch_result) = self
.cache
.try_get_or_fetch(username, || client.fetch_user_destinations(username))
.await;
match cache_outcome {
CacheOutcome::Hit => debug!(
service_name = crate::service_name(),
service_version = crate::service_version(),
event_name = "auth.ecpds.cache.hit",
username,
"ECPDS destination cache hit"
),
CacheOutcome::MissCoalesced | CacheOutcome::MissFetched { .. } => debug!(
service_name = crate::service_name(),
service_version = crate::service_version(),
event_name = "auth.ecpds.cache.miss",
username,
"ECPDS destination cache miss"
),
}
let result = match fetch_result {
Ok(destinations) => {
if destinations.contains(destination) {
Ok(())
} else {
Err(EcpdsError::AccessDenied {
reason: DenyReason::DestinationNotInList,
message: format!(
"User '{}' does not have access to destination '{}'",
username, destination
),
})
}
}
Err(e) => Err(e),
};
AccessCheckResult {
cache_outcome: Some(cache_outcome),
result,
}
}
pub fn cache_entry_count(&self) -> u64 {
self.cache.entry_count()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::EcpdsConfig;
use std::collections::HashMap;
fn make_checker_config() -> EcpdsConfig {
EcpdsConfig {
username: "masteruser".to_string(),
password: "pass".to_string(),
target_field: "name".to_string(),
match_key: "destination".to_string(),
cache_ttl_seconds: 300,
max_entries: 1000,
request_timeout_seconds: 30,
connect_timeout_seconds: 5,
partial_outage_policy: crate::config::PartialOutagePolicy::Strict,
servers: vec!["http://localhost:1".to_string()],
}
}
fn make_identifier(destination: &str) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("destination".to_string(), destination.to_string());
m
}
#[tokio::test]
async fn access_granted_when_destination_in_cached_list() {
let config = make_checker_config();
let checker = EcpdsChecker::new(&config).expect("checker must build");
checker
.cache
.set("john", vec!["CIP".to_string(), "FOO".to_string()])
.await;
let access = checker.check_access("john", &make_identifier("CIP")).await;
access.result.expect("must succeed");
assert_eq!(access.cache_outcome, Some(CacheOutcome::Hit));
}
#[tokio::test]
async fn access_denied_after_cache_hit_still_reports_hit_outcome() {
let config = make_checker_config();
let checker = EcpdsChecker::new(&config).expect("checker must build");
checker.cache.set("john", vec!["CIP".to_string()]).await;
let access = checker.check_access("john", &make_identifier("BAR")).await;
assert!(matches!(
access.result,
Err(EcpdsError::AccessDenied { .. })
));
assert_eq!(
access.cache_outcome,
Some(CacheOutcome::Hit),
"deny must still propagate cache_outcome so the route layer \
increments aviso_ecpds_cache_hits_total even on the deny path"
);
}
#[tokio::test]
async fn access_denied_when_match_key_missing_yields_no_cache_outcome() {
let config = make_checker_config();
let checker = EcpdsChecker::new(&config).expect("checker must build");
let empty: HashMap<String, String> = HashMap::new();
let access = checker.check_access("john", &empty).await;
assert!(matches!(
access.result,
Err(EcpdsError::AccessDenied { .. })
));
assert!(
access.cache_outcome.is_none(),
"MatchKeyMissing fails before any cache lookup so cache_outcome \
must be None to avoid bogus cache_misses_total increments"
);
}
#[tokio::test]
async fn service_unavailable_when_cache_miss_and_server_down_still_reports_outcome() {
let config = make_checker_config();
let checker = EcpdsChecker::new(&config).expect("checker must build");
let access = checker.check_access("john", &make_identifier("CIP")).await;
assert!(matches!(
access.result,
Err(EcpdsError::ServiceUnavailable { .. })
));
assert!(
matches!(access.cache_outcome, Some(CacheOutcome::MissFetched { .. })),
"ServiceUnavailable from a self-fetched failure must still \
propagate MissFetched so fetch_total is labelled with the \
real failure outcome (e.g. unreachable) on this path"
);
}
}