1use std::collections::hash_map::DefaultHasher;
21use std::hash::{Hash, Hasher};
22use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
23use std::time::{Duration, Instant};
24
25use arete_auth::Limits;
26use dashmap::mapref::entry::Entry;
27use dashmap::DashMap;
28
29pub const DEFAULT_MAX_TRACKED_ACCOUNTS: usize = 100_000;
31
32pub const DEFAULT_ACCOUNT_IDLE_TTL: Duration = Duration::from_secs(15 * 60);
34
35pub fn redact_identity(value: &str) -> String {
40 let mut hasher = DefaultHasher::new();
41 value.hash(&mut hasher);
42 format!("{:016x}", hasher.finish())
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47pub enum AccountPolicyError {
48 #[error("token policy version {presented} is stale; runtime has observed {current}")]
50 StaleVersion { presented: u32, current: u32 },
51 #[error("token policy version {version} conflicts with previously observed limits")]
53 ConflictingLimits { version: u32 },
54 #[error("account policy state is at capacity")]
56 CapacityExhausted,
57}
58
59#[derive(Debug, Clone)]
60struct AccountPolicyEntry {
61 version: u32,
62 limits: Limits,
63 last_seen: Instant,
64}
65
66#[derive(Debug)]
68pub struct AccountPolicyRegistry {
69 entries: DashMap<String, AccountPolicyEntry>,
70 max_entries: usize,
71 idle_ttl: Duration,
72 legacy_tokens: AtomicU64,
73}
74
75impl Default for AccountPolicyRegistry {
76 fn default() -> Self {
77 Self::new(DEFAULT_MAX_TRACKED_ACCOUNTS, DEFAULT_ACCOUNT_IDLE_TTL)
78 }
79}
80
81impl AccountPolicyRegistry {
82 pub fn new(max_entries: usize, idle_ttl: Duration) -> Self {
84 Self {
85 entries: DashMap::new(),
86 max_entries,
87 idle_ttl,
88 legacy_tokens: AtomicU64::new(0),
89 }
90 }
91
92 fn apply(
94 entry: &mut AccountPolicyEntry,
95 version: u32,
96 limits: &Limits,
97 ) -> Result<(), AccountPolicyError> {
98 entry.last_seen = Instant::now();
99 match version.cmp(&entry.version) {
100 std::cmp::Ordering::Greater => {
101 entry.version = version;
102 entry.limits = limits.clone();
103 Ok(())
104 }
105 std::cmp::Ordering::Less => Err(AccountPolicyError::StaleVersion {
106 presented: version,
107 current: entry.version,
108 }),
109 std::cmp::Ordering::Equal => {
110 if &entry.limits == limits {
111 Ok(())
112 } else {
113 Err(AccountPolicyError::ConflictingLimits { version })
114 }
115 }
116 }
117 }
118
119 pub fn observe(
122 &self,
123 account: &str,
124 version: u32,
125 limits: &Limits,
126 ) -> Result<(), AccountPolicyError> {
127 if let Some(mut entry) = self.entries.get_mut(account) {
130 return Self::apply(&mut entry, version, limits);
131 }
132
133 if self.entries.len() >= self.max_entries {
136 self.evict_idle(|_| false);
137 if self.entries.len() >= self.max_entries {
138 return Err(AccountPolicyError::CapacityExhausted);
139 }
140 }
141
142 match self.entries.entry(account.to_string()) {
148 Entry::Occupied(mut occupied) => Self::apply(occupied.get_mut(), version, limits),
149 Entry::Vacant(vacant) => {
150 vacant.insert(AccountPolicyEntry {
151 version,
152 limits: limits.clone(),
153 last_seen: Instant::now(),
154 });
155 Ok(())
156 }
157 }
158 }
159
160 pub fn limits_for(&self, account: &str) -> Option<Limits> {
162 self.entries.get(account).map(|entry| entry.limits.clone())
163 }
164
165 pub fn record_legacy_token(&self) -> u64 {
167 self.legacy_tokens.fetch_add(1, AtomicOrdering::Relaxed) + 1
168 }
169
170 pub fn legacy_token_count(&self) -> u64 {
172 self.legacy_tokens.load(AtomicOrdering::Relaxed)
173 }
174
175 pub fn tracked_accounts(&self) -> usize {
177 self.entries.len()
178 }
179
180 pub fn evict_idle(&self, is_live: impl Fn(&str) -> bool) {
182 let ttl = self.idle_ttl;
183 self.entries
184 .retain(|account, entry| is_live(account) || entry.last_seen.elapsed() < ttl);
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn limits(max_connections: u32) -> Limits {
193 Limits {
194 max_connections: Some(max_connections),
195 ..Limits::default()
196 }
197 }
198
199 #[test]
200 fn version_upgrade_replaces_limits_and_stale_or_conflicting_tokens_reject() {
201 let registry = AccountPolicyRegistry::default();
202
203 registry.observe("account:1", 1, &limits(5)).unwrap();
204 assert_eq!(registry.limits_for("account:1"), Some(limits(5)));
205
206 registry.observe("account:1", 1, &limits(5)).unwrap();
208
209 registry.observe("account:1", 3, &limits(2)).unwrap();
211 assert_eq!(registry.limits_for("account:1"), Some(limits(2)));
212
213 assert_eq!(
215 registry.observe("account:1", 2, &limits(9)),
216 Err(AccountPolicyError::StaleVersion {
217 presented: 2,
218 current: 3
219 })
220 );
221
222 assert_eq!(
224 registry.observe("account:1", 3, &limits(9)),
225 Err(AccountPolicyError::ConflictingLimits { version: 3 })
226 );
227
228 registry.observe("account:2", 1, &limits(1)).unwrap();
230 }
231
232 #[test]
233 fn concurrent_first_admissions_never_register_an_older_version() {
234 use std::sync::Arc;
235
236 for _ in 0..64 {
239 let registry = Arc::new(AccountPolicyRegistry::default());
240 let threads: Vec<_> = (1..=8u32)
241 .map(|version| {
242 let registry = Arc::clone(®istry);
243 std::thread::spawn(move || {
244 let _ = registry.observe("account:1", version, &limits(version));
246 })
247 })
248 .collect();
249 for thread in threads {
250 thread.join().expect("observer thread");
251 }
252
253 assert_eq!(
255 registry.limits_for("account:1"),
256 Some(limits(8)),
257 "a lower policy version overwrote a newer one"
258 );
259 assert_eq!(
260 registry.observe("account:1", 7, &limits(7)),
261 Err(AccountPolicyError::StaleVersion {
262 presented: 7,
263 current: 8
264 })
265 );
266 }
267 }
268
269 #[test]
270 fn idle_entries_evict_and_capacity_is_bounded() {
271 let registry = AccountPolicyRegistry::new(2, Duration::from_secs(0));
272 registry.observe("account:1", 1, &limits(1)).unwrap();
273 registry.observe("account:2", 1, &limits(1)).unwrap();
274
275 registry.observe("account:3", 1, &limits(1)).unwrap();
278 assert!(registry.tracked_accounts() <= 2);
279
280 let full = AccountPolicyRegistry::new(1, Duration::from_secs(3600));
282 full.observe("account:1", 1, &limits(1)).unwrap();
283 assert_eq!(
284 full.observe("account:2", 1, &limits(1)),
285 Err(AccountPolicyError::CapacityExhausted)
286 );
287
288 full.evict_idle(|_| false);
289 assert_eq!(full.tracked_accounts(), 1, "live TTL keeps entries");
290 }
291
292 #[test]
293 fn legacy_counter_accumulates() {
294 let registry = AccountPolicyRegistry::default();
295 assert_eq!(registry.legacy_token_count(), 0);
296 assert_eq!(registry.record_legacy_token(), 1);
297 assert_eq!(registry.record_legacy_token(), 2);
298 assert_eq!(registry.legacy_token_count(), 2);
299 }
300
301 #[test]
302 fn redacted_identities_are_stable_and_not_raw() {
303 let redacted = redact_identity("account:42");
304 assert_eq!(redacted, redact_identity("account:42"));
305 assert_ne!(redacted, "account:42");
306 assert_eq!(redacted.len(), 16);
307 assert!(redacted.bytes().all(|byte| byte.is_ascii_hexdigit()));
308 }
309}