stygian_charon/token_lifecycle/policy.rs
1//! Vendor-aware token policy table (T91).
2//!
3//! The [`TokenPolicyTable`] is the lookup the
4//! [`TokenValidator`][crate::token_lifecycle::TokenValidator]
5//! consults before applying a [`TokenContract`][crate::token_lifecycle::TokenContract].
6//! It carries four knobs per vendor family:
7//!
8//! - **Default TTL**: the TTL a freshly-issued token is
9//! expected to carry.
10//! - **Max TTL**: the upper bound the validator will accept.
11//! Contracts with a longer TTL are **clamped** to `max_ttl`
12//! before the validator applies the TTL check.
13//! - **`require_nonce`**: whether the validator must enforce
14//! per-issuance nonce binding. Off by default for
15//! [`ChallengeClass::None`][crate::token_lifecycle::ChallengeClass::None]
16//! tokens (cookies); on for every other challenge class.
17//! - **`single_use`**: the per-vendor default for
18//! [`TokenContract::single_use`][crate::token_lifecycle::TokenContract::single_use].
19//! The validator uses this **only** when the contract's own
20//! `single_use` field is not supplied; the contract field
21//! always wins.
22//! - **`require_session_binding`**: whether the validator must
23//! enforce sticky-session binding. Off by default for
24//! [`ChallengeClass::CookieRefresh`][crate::token_lifecycle::ChallengeClass::CookieRefresh]
25//! — except when the per-vendor policy overrides the default.
26
27use std::collections::BTreeMap;
28use std::time::Duration;
29
30use serde::{Deserialize, Serialize};
31
32use crate::vendor_classifier::VendorId;
33
34/// Per-vendor defaults for the
35/// [`TokenValidator`][crate::token_lifecycle::TokenValidator].
36///
37/// Every field is documented in the
38/// [module docs][crate::token_lifecycle#vendor-policy-table].
39/// The defaults are the values baked into
40/// [`builtin_token_policies`]; operators can override per-vendor
41/// with [`TokenPolicyTable::with_policy`].
42///
43/// # Example
44///
45/// ```
46/// use std::time::Duration;
47/// use stygian_charon::token_lifecycle::TokenPolicy;
48/// use stygian_charon::vendor_classifier::VendorId;
49///
50/// let policy = TokenPolicy::default_for(VendorId::Cloudflare);
51/// assert_eq!(policy.default_ttl(), Duration::from_mins(30));
52/// assert!(policy.single_use());
53/// ```
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub struct TokenPolicy {
56 /// Default TTL a freshly-issued token is expected to carry.
57 default_ttl: Duration,
58 /// Upper bound the validator will accept before clamping.
59 max_ttl: Duration,
60 /// Whether the validator must enforce per-issuance nonce
61 /// binding.
62 require_nonce: bool,
63 /// Per-vendor default for the single-use flag.
64 single_use: bool,
65 /// Whether the validator must enforce sticky-session
66 /// binding.
67 require_session_binding: bool,
68}
69
70impl TokenPolicy {
71 /// Build a [`TokenPolicy`] with explicit values. The
72 /// constructor clamps `default_ttl` to `max_ttl` so a
73 /// caller cannot accidentally build a policy whose default
74 /// is longer than its maximum.
75 #[must_use]
76 pub fn new(
77 default_ttl: Duration,
78 max_ttl: Duration,
79 require_nonce: bool,
80 single_use: bool,
81 require_session_binding: bool,
82 ) -> Self {
83 let default_ttl = if default_ttl > max_ttl {
84 max_ttl
85 } else {
86 default_ttl
87 };
88 Self {
89 default_ttl,
90 max_ttl,
91 require_nonce,
92 single_use,
93 require_session_binding,
94 }
95 }
96
97 /// Replace the default TTL. The new value is clamped to
98 /// the current `max_ttl` so the policy invariant
99 /// (`max_ttl >= default_ttl`) is preserved.
100 ///
101 /// # Example
102 ///
103 /// ```
104 /// use std::time::Duration;
105 /// use stygian_charon::token_lifecycle::TokenPolicy;
106 ///
107 /// let p = TokenPolicy::default_for(stygian_charon::vendor_classifier::VendorId::Cloudflare);
108 /// let tighter = p.with_default_ttl(Duration::from_mins(5));
109 /// assert_eq!(tighter.default_ttl(), Duration::from_mins(5));
110 /// ```
111 #[must_use]
112 pub fn with_default_ttl(mut self, default_ttl: Duration) -> Self {
113 self.default_ttl = if default_ttl > self.max_ttl {
114 self.max_ttl
115 } else {
116 default_ttl
117 };
118 self
119 }
120
121 /// Replace the maximum TTL.
122 ///
123 /// # Example
124 ///
125 /// ```
126 /// use std::time::Duration;
127 /// use stygian_charon::token_lifecycle::TokenPolicy;
128 ///
129 /// let p = TokenPolicy::default_for(stygian_charon::vendor_classifier::VendorId::Cloudflare);
130 /// let tighter = p.with_max_ttl(Duration::from_mins(20));
131 /// assert_eq!(tighter.max_ttl(), Duration::from_mins(20));
132 /// ```
133 #[must_use]
134 pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
135 self.max_ttl = max_ttl;
136 if self.default_ttl > max_ttl {
137 self.default_ttl = max_ttl;
138 }
139 self
140 }
141
142 /// Default TTL baked into this policy.
143 #[must_use]
144 pub const fn default_ttl(&self) -> Duration {
145 self.default_ttl
146 }
147
148 /// Maximum TTL the validator will accept.
149 #[must_use]
150 pub const fn max_ttl(&self) -> Duration {
151 self.max_ttl
152 }
153
154 /// Whether per-issuance nonce binding is required.
155 #[must_use]
156 pub const fn require_nonce(&self) -> bool {
157 self.require_nonce
158 }
159
160 /// Per-vendor default for the single-use flag.
161 #[must_use]
162 pub const fn single_use(&self) -> bool {
163 self.single_use
164 }
165
166 /// Whether sticky-session binding is required.
167 #[must_use]
168 pub const fn require_session_binding(&self) -> bool {
169 self.require_session_binding
170 }
171
172 /// Per-vendor default policy matching the
173 /// [vendor policy table][crate::token_lifecycle#vendor-policy-table].
174 ///
175 /// # Example
176 ///
177 /// ```
178 /// use std::time::Duration;
179 /// use stygian_charon::token_lifecycle::TokenPolicy;
180 /// use stygian_charon::vendor_classifier::VendorId;
181 ///
182 /// assert_eq!(TokenPolicy::default_for(VendorId::Cloudflare).default_ttl(), Duration::from_mins(30));
183 /// assert_eq!(TokenPolicy::default_for(VendorId::DataDome).default_ttl(), Duration::from_mins(10));
184 /// assert_eq!(TokenPolicy::default_for(VendorId::Unknown).default_ttl(), Duration::from_mins(5));
185 /// ```
186 #[must_use]
187 pub fn default_for(vendor: VendorId) -> Self {
188 match vendor {
189 VendorId::Cloudflare => Self::new(
190 Duration::from_mins(30),
191 Duration::from_mins(45),
192 true,
193 true,
194 false,
195 ),
196 VendorId::Akamai | VendorId::PerimeterX | VendorId::Imperva => Self::new(
197 Duration::from_mins(15),
198 Duration::from_mins(30),
199 true,
200 true,
201 true,
202 ),
203 VendorId::DataDome | VendorId::ShapeSecurity => Self::new(
204 Duration::from_mins(10),
205 Duration::from_mins(20),
206 true,
207 true,
208 true,
209 ),
210 VendorId::Hcaptcha | VendorId::Recaptcha | VendorId::Unknown => Self::new(
211 Duration::from_mins(5),
212 Duration::from_mins(10),
213 true,
214 true,
215 false,
216 ),
217 VendorId::Kasada => Self::new(
218 Duration::from_mins(5),
219 Duration::from_mins(10),
220 true,
221 true,
222 true,
223 ),
224 VendorId::FingerprintCom => Self::new(
225 Duration::from_hours(1),
226 Duration::from_hours(2),
227 true,
228 false,
229 false,
230 ),
231 }
232 }
233}
234
235/// Per-vendor policy lookup table.
236///
237/// The table is keyed by [`VendorId`] and consults the per-vendor
238/// [`TokenPolicy::default_for`] when a vendor is not explicitly
239/// registered. Callers can override per-vendor with
240/// [`with_policy`][Self::with_policy].
241///
242/// The default-on path is
243/// [`TokenPolicyTable::with_builtin_defaults`], which seeds the
244/// table with every vendor the T89 classifier knows about
245/// (Tier 1 + Tier 2 + the `Unknown` fallback).
246///
247/// # Example
248///
249/// ```
250/// use std::time::Duration;
251/// use stygian_charon::token_lifecycle::TokenPolicyTable;
252/// use stygian_charon::vendor_classifier::VendorId;
253///
254/// let mut table = TokenPolicyTable::with_builtin_defaults();
255/// // Override Cloudflare to a stricter 5-minute default TTL.
256/// let tighter = table.policy(VendorId::Cloudflare).with_default_ttl(Duration::from_mins(5));
257/// table = table.with_policy(VendorId::Cloudflare, tighter);
258/// assert_eq!(table.policy(VendorId::Cloudflare).default_ttl(), Duration::from_mins(5));
259/// ```
260#[derive(Debug, Clone, Default)]
261pub struct TokenPolicyTable {
262 overrides: BTreeMap<VendorId, TokenPolicy>,
263}
264
265impl TokenPolicyTable {
266 /// Build an empty table (no overrides; every lookup returns
267 /// the [`TokenPolicy::default_for`] baseline).
268 ///
269 /// # Example
270 ///
271 /// ```
272 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
273 /// use stygian_charon::vendor_classifier::VendorId;
274 ///
275 /// let table = TokenPolicyTable::empty();
276 /// assert!(table.is_empty());
277 /// ```
278 #[must_use]
279 pub fn empty() -> Self {
280 Self::default()
281 }
282
283 /// Build a table seeded with the per-vendor defaults for
284 /// every [`VendorId`] variant. The `Unknown` vendor is
285 /// always included as the catch-all fallback.
286 ///
287 /// # Example
288 ///
289 /// ```
290 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
291 /// use stygian_charon::vendor_classifier::VendorId;
292 ///
293 /// let table = TokenPolicyTable::with_builtin_defaults();
294 /// assert!(!table.is_empty());
295 /// assert!(table.contains(VendorId::Cloudflare));
296 /// assert!(table.contains(VendorId::DataDome));
297 /// assert!(table.contains(VendorId::Unknown));
298 /// ```
299 #[must_use]
300 pub fn with_builtin_defaults() -> Self {
301 let mut overrides = BTreeMap::new();
302 for vendor in builtin_token_policies() {
303 overrides.insert(vendor.0, vendor.1);
304 }
305 Self { overrides }
306 }
307
308 /// `true` when the table has no overrides registered.
309 #[must_use]
310 pub fn is_empty(&self) -> bool {
311 self.overrides.is_empty()
312 }
313
314 /// `true` when the table has an override registered for
315 /// `vendor`.
316 ///
317 /// # Example
318 ///
319 /// ```
320 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
321 /// use stygian_charon::vendor_classifier::VendorId;
322 ///
323 /// let table = TokenPolicyTable::with_builtin_defaults();
324 /// assert!(table.contains(VendorId::Cloudflare));
325 /// ```
326 #[must_use]
327 pub fn contains(&self, vendor: VendorId) -> bool {
328 self.overrides.contains_key(&vendor)
329 }
330
331 /// Number of vendors currently registered (including the
332 /// `Unknown` fallback).
333 #[must_use]
334 pub fn len(&self) -> usize {
335 self.overrides.len()
336 }
337
338 /// Per-vendor policy. Returns the override if one is
339 /// registered, otherwise the [`TokenPolicy::default_for`]
340 /// baseline for that vendor.
341 ///
342 /// # Example
343 ///
344 /// ```
345 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
346 /// use stygian_charon::vendor_classifier::VendorId;
347 ///
348 /// let table = TokenPolicyTable::with_builtin_defaults();
349 /// let policy = table.policy(VendorId::Akamai);
350 /// assert!(policy.require_session_binding());
351 /// ```
352 #[must_use]
353 pub fn policy(&self, vendor: VendorId) -> TokenPolicy {
354 self.overrides
355 .get(&vendor)
356 .copied()
357 .unwrap_or_else(|| TokenPolicy::default_for(vendor))
358 }
359
360 /// Register an override for `vendor`. The override
361 /// **replaces** any existing entry.
362 ///
363 /// # Example
364 ///
365 /// ```
366 /// use std::time::Duration;
367 /// use stygian_charon::token_lifecycle::{TokenPolicy, TokenPolicyTable};
368 /// use stygian_charon::vendor_classifier::VendorId;
369 ///
370 /// let mut table = TokenPolicyTable::with_builtin_defaults();
371 /// let override_policy = TokenPolicy::new(
372 /// Duration::from_mins(1),
373 /// Duration::from_mins(2),
374 /// true,
375 /// true,
376 /// true,
377 /// );
378 /// table = table.with_policy(VendorId::PerimeterX, override_policy);
379 /// assert_eq!(table.policy(VendorId::PerimeterX).default_ttl(), Duration::from_mins(1));
380 /// ```
381 #[must_use]
382 pub fn with_policy(mut self, vendor: VendorId, policy: TokenPolicy) -> Self {
383 self.overrides.insert(vendor, policy);
384 self
385 }
386
387 /// Ids of every vendor currently registered (including the
388 /// `Unknown` fallback when present).
389 #[must_use]
390 pub fn vendors(&self) -> Vec<VendorId> {
391 self.overrides.keys().copied().collect()
392 }
393}
394
395/// Snapshot of the built-in per-vendor policy table.
396///
397/// Returns `(vendor, policy)` pairs in [`VendorId`] discriminant
398/// order so the JSON form is byte-stable. Used by
399/// [`TokenPolicyTable::with_builtin_defaults`] and by the
400/// compile-time validation in
401/// `compile_check_builtin_token_policies`.
402///
403/// # Example
404///
405/// ```
406/// use stygian_charon::token_lifecycle::builtin_token_policies;
407///
408/// let rows = builtin_token_policies();
409/// assert!(rows.iter().any(|(v, _)| *v == stygian_charon::vendor_classifier::VendorId::Cloudflare));
410/// ```
411#[must_use]
412pub fn builtin_token_policies() -> Vec<(VendorId, TokenPolicy)> {
413 [
414 VendorId::Akamai,
415 VendorId::Cloudflare,
416 VendorId::DataDome,
417 VendorId::PerimeterX,
418 VendorId::Hcaptcha,
419 VendorId::Recaptcha,
420 VendorId::Kasada,
421 VendorId::FingerprintCom,
422 VendorId::ShapeSecurity,
423 VendorId::Imperva,
424 VendorId::Unknown,
425 ]
426 .iter()
427 .map(|v| (*v, TokenPolicy::default_for(*v)))
428 .collect()
429}
430
431/// Compile-time guarantee that every baseline policy the
432/// built-in table seeds is well-formed.
433///
434/// Used by the
435/// `compile_check_builtin_token_policies` test in the module
436/// tests block below.
437#[doc(hidden)]
438#[allow(dead_code)]
439pub fn compile_check_builtin_token_policies() {
440 for (vendor, policy) in builtin_token_policies() {
441 assert!(policy.max_ttl() >= policy.default_ttl());
442 let _ = vendor;
443 }
444}
445
446#[cfg(test)]
447#[allow(
448 clippy::unwrap_used,
449 clippy::expect_used,
450 clippy::panic,
451 clippy::indexing_slicing
452)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn token_policy_clamps_default_ttl_to_max_ttl() {
458 let policy = TokenPolicy::new(
459 Duration::from_hours(1),
460 Duration::from_mins(10),
461 true,
462 true,
463 false,
464 );
465 assert_eq!(policy.default_ttl(), Duration::from_mins(10));
466 assert_eq!(policy.max_ttl(), Duration::from_mins(10));
467 }
468
469 #[test]
470 fn vendor_default_policies_match_module_table() {
471 assert_eq!(
472 TokenPolicy::default_for(VendorId::Cloudflare).default_ttl(),
473 Duration::from_mins(30)
474 );
475 assert_eq!(
476 TokenPolicy::default_for(VendorId::Cloudflare).max_ttl(),
477 Duration::from_mins(45)
478 );
479 assert_eq!(
480 TokenPolicy::default_for(VendorId::Akamai).default_ttl(),
481 Duration::from_mins(15)
482 );
483 assert_eq!(
484 TokenPolicy::default_for(VendorId::DataDome).default_ttl(),
485 Duration::from_mins(10)
486 );
487 assert_eq!(
488 TokenPolicy::default_for(VendorId::PerimeterX).default_ttl(),
489 Duration::from_mins(15)
490 );
491 assert_eq!(
492 TokenPolicy::default_for(VendorId::Hcaptcha).default_ttl(),
493 Duration::from_mins(5)
494 );
495 assert_eq!(
496 TokenPolicy::default_for(VendorId::Recaptcha).default_ttl(),
497 Duration::from_mins(5)
498 );
499 assert_eq!(
500 TokenPolicy::default_for(VendorId::Kasada).default_ttl(),
501 Duration::from_mins(5)
502 );
503 assert_eq!(
504 TokenPolicy::default_for(VendorId::FingerprintCom).default_ttl(),
505 Duration::from_hours(1)
506 );
507 assert_eq!(
508 TokenPolicy::default_for(VendorId::ShapeSecurity).default_ttl(),
509 Duration::from_mins(10)
510 );
511 assert_eq!(
512 TokenPolicy::default_for(VendorId::Imperva).default_ttl(),
513 Duration::from_mins(15)
514 );
515 assert_eq!(
516 TokenPolicy::default_for(VendorId::Unknown).default_ttl(),
517 Duration::from_mins(5)
518 );
519 }
520
521 #[test]
522 fn default_for_includes_required_session_binding_for_tier2() {
523 // Tier 2 vendors require session binding by default.
524 assert!(TokenPolicy::default_for(VendorId::DataDome).require_session_binding());
525 assert!(TokenPolicy::default_for(VendorId::PerimeterX).require_session_binding());
526 assert!(TokenPolicy::default_for(VendorId::Akamai).require_session_binding());
527 // Tier 1 / fingerprint vendors do not.
528 assert!(!TokenPolicy::default_for(VendorId::Cloudflare).require_session_binding());
529 assert!(!TokenPolicy::default_for(VendorId::Hcaptcha).require_session_binding());
530 assert!(!TokenPolicy::default_for(VendorId::FingerprintCom).require_session_binding());
531 }
532
533 #[test]
534 fn builtin_policies_cover_every_vendor_in_taxonomy() {
535 let rows = builtin_token_policies();
536 assert!(rows.iter().any(|(v, _)| *v == VendorId::Unknown));
537 assert_eq!(rows.len(), 11);
538 }
539
540 #[test]
541 fn compile_check_builtin_token_policies_passes_for_builtins() {
542 // Re-run the runtime compile-check helper to make sure
543 // it executes end-to-end against the built-in table.
544 compile_check_builtin_token_policies();
545 }
546
547 #[test]
548 fn policy_table_lookup_returns_override_or_default() {
549 let mut table = TokenPolicyTable::empty();
550 // Empty table: lookups return TokenPolicy::default_for().
551 assert_eq!(
552 table.policy(VendorId::Cloudflare).default_ttl(),
553 TokenPolicy::default_for(VendorId::Cloudflare).default_ttl()
554 );
555
556 // Register an override.
557 let override_policy = TokenPolicy::new(
558 Duration::from_mins(1),
559 Duration::from_mins(2),
560 true,
561 true,
562 true,
563 );
564 table = table.with_policy(VendorId::Cloudflare, override_policy);
565 assert_eq!(
566 table.policy(VendorId::Cloudflare).default_ttl(),
567 Duration::from_mins(1)
568 );
569 // Non-overridden vendor still returns the baseline.
570 assert_eq!(
571 table.policy(VendorId::DataDome).default_ttl(),
572 TokenPolicy::default_for(VendorId::DataDome).default_ttl()
573 );
574 }
575
576 #[test]
577 fn with_builtin_defaults_seeds_every_vendor() {
578 let table = TokenPolicyTable::with_builtin_defaults();
579 for vendor in [
580 VendorId::Akamai,
581 VendorId::Cloudflare,
582 VendorId::DataDome,
583 VendorId::PerimeterX,
584 VendorId::Hcaptcha,
585 VendorId::Recaptcha,
586 VendorId::Kasada,
587 VendorId::FingerprintCom,
588 VendorId::ShapeSecurity,
589 VendorId::Imperva,
590 VendorId::Unknown,
591 ] {
592 assert!(
593 table.contains(vendor),
594 "missing builtin policy for {vendor:?}"
595 );
596 }
597 assert!(!table.is_empty());
598 }
599
600 #[test]
601 fn policy_table_is_additive_after_override() {
602 let table = TokenPolicyTable::with_builtin_defaults().with_policy(
603 VendorId::Cloudflare,
604 TokenPolicy::new(
605 Duration::from_mins(1),
606 Duration::from_mins(2),
607 true,
608 true,
609 true,
610 ),
611 );
612 // Cloudflare override applied.
613 assert_eq!(
614 table.policy(VendorId::Cloudflare).default_ttl(),
615 Duration::from_mins(1)
616 );
617 // Akamai untouched.
618 assert_eq!(
619 table.policy(VendorId::Akamai).default_ttl(),
620 Duration::from_mins(15)
621 );
622 }
623}