Skip to main content

arete_server/
account_policy.rs

1//! Shared account policy state for runtime enforcement.
2//!
3//! A signed session token carries a monotonic `policy_version` and an
4//! aggregate `account_limits` object for its billing account. Each runtime
5//! process keeps one [`AccountPolicyRegistry`] per enforcement surface and
6//! observes every non-legacy token at admission time:
7//!
8//! - the first token for an account creates state;
9//! - a higher policy version atomically replaces the stored limits;
10//! - a lower policy version is rejected as stale once a newer version has
11//!   been observed;
12//! - the same version with different limits is rejected and logged as a
13//!   signing/configuration fault.
14//!
15//! A downgrade affects new admissions immediately but never kills an
16//! existing socket; normal token refresh/expiry drains it. Entries are
17//! bounded and evicted after an idle TTL so signed-but-idle accounts cannot
18//! grow the map forever.
19
20use 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
29/// Default bound on tracked account policy entries per registry.
30pub const DEFAULT_MAX_TRACKED_ACCOUNTS: usize = 100_000;
31
32/// Default idle TTL after which unused account state is evicted.
33pub const DEFAULT_ACCOUNT_IDLE_TTL: Duration = Duration::from_secs(15 * 60);
34
35/// Hash an account/consumer identity for routine logs.
36///
37/// Raw identities must not appear in routine logs; this produces a stable
38/// low-cardinality token suitable for correlation.
39pub 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/// Failure to admit a token against previously observed account policy.
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47pub enum AccountPolicyError {
48    /// The token carries an older policy version than this runtime has seen.
49    #[error("token policy version {presented} is stale; runtime has observed {current}")]
50    StaleVersion { presented: u32, current: u32 },
51    /// The token repeats an observed policy version with different limits.
52    #[error("token policy version {version} conflicts with previously observed limits")]
53    ConflictingLimits { version: u32 },
54    /// The registry is full and no idle entry could be evicted.
55    #[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/// Bounded per-process registry of account policy versions and limits.
67#[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    /// Create a registry bounded to `max_entries` with the given idle TTL.
83    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    /// Apply the policy-version rules to an entry already held under lock.
93    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    /// Observe a signed (account, policy version, account limits) tuple and
120    /// apply the policy-version conflict rules.
121    pub fn observe(
122        &self,
123        account: &str,
124        version: u32,
125        limits: &Limits,
126    ) -> Result<(), AccountPolicyError> {
127        // Fast path: an existing entry is updated under its own lock, and
128        // avoids allocating the owned key the entry API needs.
129        if let Some(mut entry) = self.entries.get_mut(account) {
130            return Self::apply(&mut entry, version, limits);
131        }
132
133        // Make room before taking an entry lock: `evict_idle` retains over
134        // the whole map and must not run while a lock is held.
135        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        // Re-check under the entry lock. Two first admissions for one account
143        // can both miss the fast path; without this the later insert would
144        // clobber a newer version with an older one and leave stale limits
145        // registered. The capacity bound above is a backstop, so overshooting
146        // it by the number of racing threads is acceptable.
147        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    /// Stored limits for an account, if this runtime has observed one.
161    pub fn limits_for(&self, account: &str) -> Option<Limits> {
162        self.entries.get(account).map(|entry| entry.limits.clone())
163    }
164
165    /// Record one legacy (pre-v2) token observation and return the total.
166    pub fn record_legacy_token(&self) -> u64 {
167        self.legacy_tokens.fetch_add(1, AtomicOrdering::Relaxed) + 1
168    }
169
170    /// Total legacy tokens observed since process start.
171    pub fn legacy_token_count(&self) -> u64 {
172        self.legacy_tokens.load(AtomicOrdering::Relaxed)
173    }
174
175    /// Number of tracked accounts.
176    pub fn tracked_accounts(&self) -> usize {
177        self.entries.len()
178    }
179
180    /// Drop entries that are idle past the TTL and not reported live.
181    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        // Same version, same limits: fine.
207        registry.observe("account:1", 1, &limits(5)).unwrap();
208
209        // Higher version replaces the limits atomically.
210        registry.observe("account:1", 3, &limits(2)).unwrap();
211        assert_eq!(registry.limits_for("account:1"), Some(limits(2)));
212
213        // Lower version is stale once a newer version has been observed.
214        assert_eq!(
215            registry.observe("account:1", 2, &limits(9)),
216            Err(AccountPolicyError::StaleVersion {
217                presented: 2,
218                current: 3
219            })
220        );
221
222        // Same version with different limits is a signing/config fault.
223        assert_eq!(
224            registry.observe("account:1", 3, &limits(9)),
225            Err(AccountPolicyError::ConflictingLimits { version: 3 })
226        );
227
228        // Other accounts are unaffected.
229        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        // Racing first admissions for one account: every thread misses the
237        // fast path, so the insert must not clobber a newer version.
238        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(&registry);
243                    std::thread::spawn(move || {
244                        // Limits vary with the version so a clobber is visible.
245                        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            // The highest version always lands, and nothing may downgrade it.
254            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        // At capacity with a zero TTL: the idle entries are evicted to make
276        // room instead of failing.
277        registry.observe("account:3", 1, &limits(1)).unwrap();
278        assert!(registry.tracked_accounts() <= 2);
279
280        // With every entry live, capacity is a hard bound.
281        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}