use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::time::{Duration, Instant};
use arete_auth::Limits;
use dashmap::mapref::entry::Entry;
use dashmap::DashMap;
pub const DEFAULT_MAX_TRACKED_ACCOUNTS: usize = 100_000;
pub const DEFAULT_ACCOUNT_IDLE_TTL: Duration = Duration::from_secs(15 * 60);
pub fn redact_identity(value: &str) -> String {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AccountPolicyError {
#[error("token policy version {presented} is stale; runtime has observed {current}")]
StaleVersion { presented: u32, current: u32 },
#[error("token policy version {version} conflicts with previously observed limits")]
ConflictingLimits { version: u32 },
#[error("account policy state is at capacity")]
CapacityExhausted,
}
#[derive(Debug, Clone)]
struct AccountPolicyEntry {
version: u32,
limits: Limits,
last_seen: Instant,
}
#[derive(Debug)]
pub struct AccountPolicyRegistry {
entries: DashMap<String, AccountPolicyEntry>,
max_entries: usize,
idle_ttl: Duration,
legacy_tokens: AtomicU64,
}
impl Default for AccountPolicyRegistry {
fn default() -> Self {
Self::new(DEFAULT_MAX_TRACKED_ACCOUNTS, DEFAULT_ACCOUNT_IDLE_TTL)
}
}
impl AccountPolicyRegistry {
pub fn new(max_entries: usize, idle_ttl: Duration) -> Self {
Self {
entries: DashMap::new(),
max_entries,
idle_ttl,
legacy_tokens: AtomicU64::new(0),
}
}
fn apply(
entry: &mut AccountPolicyEntry,
version: u32,
limits: &Limits,
) -> Result<(), AccountPolicyError> {
entry.last_seen = Instant::now();
match version.cmp(&entry.version) {
std::cmp::Ordering::Greater => {
entry.version = version;
entry.limits = limits.clone();
Ok(())
}
std::cmp::Ordering::Less => Err(AccountPolicyError::StaleVersion {
presented: version,
current: entry.version,
}),
std::cmp::Ordering::Equal => {
if &entry.limits == limits {
Ok(())
} else {
Err(AccountPolicyError::ConflictingLimits { version })
}
}
}
}
pub fn observe(
&self,
account: &str,
version: u32,
limits: &Limits,
) -> Result<(), AccountPolicyError> {
if let Some(mut entry) = self.entries.get_mut(account) {
return Self::apply(&mut entry, version, limits);
}
if self.entries.len() >= self.max_entries {
self.evict_idle(|_| false);
if self.entries.len() >= self.max_entries {
return Err(AccountPolicyError::CapacityExhausted);
}
}
match self.entries.entry(account.to_string()) {
Entry::Occupied(mut occupied) => Self::apply(occupied.get_mut(), version, limits),
Entry::Vacant(vacant) => {
vacant.insert(AccountPolicyEntry {
version,
limits: limits.clone(),
last_seen: Instant::now(),
});
Ok(())
}
}
}
pub fn limits_for(&self, account: &str) -> Option<Limits> {
self.entries.get(account).map(|entry| entry.limits.clone())
}
pub fn record_legacy_token(&self) -> u64 {
self.legacy_tokens.fetch_add(1, AtomicOrdering::Relaxed) + 1
}
pub fn legacy_token_count(&self) -> u64 {
self.legacy_tokens.load(AtomicOrdering::Relaxed)
}
pub fn tracked_accounts(&self) -> usize {
self.entries.len()
}
pub fn evict_idle(&self, is_live: impl Fn(&str) -> bool) {
let ttl = self.idle_ttl;
self.entries
.retain(|account, entry| is_live(account) || entry.last_seen.elapsed() < ttl);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn limits(max_connections: u32) -> Limits {
Limits {
max_connections: Some(max_connections),
..Limits::default()
}
}
#[test]
fn version_upgrade_replaces_limits_and_stale_or_conflicting_tokens_reject() {
let registry = AccountPolicyRegistry::default();
registry.observe("account:1", 1, &limits(5)).unwrap();
assert_eq!(registry.limits_for("account:1"), Some(limits(5)));
registry.observe("account:1", 1, &limits(5)).unwrap();
registry.observe("account:1", 3, &limits(2)).unwrap();
assert_eq!(registry.limits_for("account:1"), Some(limits(2)));
assert_eq!(
registry.observe("account:1", 2, &limits(9)),
Err(AccountPolicyError::StaleVersion {
presented: 2,
current: 3
})
);
assert_eq!(
registry.observe("account:1", 3, &limits(9)),
Err(AccountPolicyError::ConflictingLimits { version: 3 })
);
registry.observe("account:2", 1, &limits(1)).unwrap();
}
#[test]
fn concurrent_first_admissions_never_register_an_older_version() {
use std::sync::Arc;
for _ in 0..64 {
let registry = Arc::new(AccountPolicyRegistry::default());
let threads: Vec<_> = (1..=8u32)
.map(|version| {
let registry = Arc::clone(®istry);
std::thread::spawn(move || {
let _ = registry.observe("account:1", version, &limits(version));
})
})
.collect();
for thread in threads {
thread.join().expect("observer thread");
}
assert_eq!(
registry.limits_for("account:1"),
Some(limits(8)),
"a lower policy version overwrote a newer one"
);
assert_eq!(
registry.observe("account:1", 7, &limits(7)),
Err(AccountPolicyError::StaleVersion {
presented: 7,
current: 8
})
);
}
}
#[test]
fn idle_entries_evict_and_capacity_is_bounded() {
let registry = AccountPolicyRegistry::new(2, Duration::from_secs(0));
registry.observe("account:1", 1, &limits(1)).unwrap();
registry.observe("account:2", 1, &limits(1)).unwrap();
registry.observe("account:3", 1, &limits(1)).unwrap();
assert!(registry.tracked_accounts() <= 2);
let full = AccountPolicyRegistry::new(1, Duration::from_secs(3600));
full.observe("account:1", 1, &limits(1)).unwrap();
assert_eq!(
full.observe("account:2", 1, &limits(1)),
Err(AccountPolicyError::CapacityExhausted)
);
full.evict_idle(|_| false);
assert_eq!(full.tracked_accounts(), 1, "live TTL keeps entries");
}
#[test]
fn legacy_counter_accumulates() {
let registry = AccountPolicyRegistry::default();
assert_eq!(registry.legacy_token_count(), 0);
assert_eq!(registry.record_legacy_token(), 1);
assert_eq!(registry.record_legacy_token(), 2);
assert_eq!(registry.legacy_token_count(), 2);
}
#[test]
fn redacted_identities_are_stable_and_not_raw() {
let redacted = redact_identity("account:42");
assert_eq!(redacted, redact_identity("account:42"));
assert_ne!(redacted, "account:42");
assert_eq!(redacted.len(), 16);
assert!(redacted.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
}