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 | VendorId::Fastly => {
197 Self::new(
198 Duration::from_mins(15),
199 Duration::from_mins(30),
200 true,
201 true,
202 true,
203 )
204 }
205 VendorId::DataDome | VendorId::ShapeSecurity => Self::new(
206 Duration::from_mins(10),
207 Duration::from_mins(20),
208 true,
209 true,
210 true,
211 ),
212 VendorId::Hcaptcha | VendorId::Recaptcha | VendorId::Unknown => Self::new(
213 Duration::from_mins(5),
214 Duration::from_mins(10),
215 true,
216 true,
217 false,
218 ),
219 VendorId::Kasada => Self::new(
220 Duration::from_mins(5),
221 Duration::from_mins(10),
222 true,
223 true,
224 true,
225 ),
226 VendorId::FingerprintCom => Self::new(
227 Duration::from_hours(1),
228 Duration::from_hours(2),
229 true,
230 false,
231 false,
232 ),
233 }
234 }
235}
236
237/// Per-vendor policy lookup table.
238///
239/// The table is keyed by [`VendorId`] and consults the per-vendor
240/// [`TokenPolicy::default_for`] when a vendor is not explicitly
241/// registered. Callers can override per-vendor with
242/// [`with_policy`][Self::with_policy].
243///
244/// The default-on path is
245/// [`TokenPolicyTable::with_builtin_defaults`], which seeds the
246/// table with every vendor the T89 classifier knows about
247/// (Tier 1 + Tier 2 + the `Unknown` fallback).
248///
249/// # Example
250///
251/// ```
252/// use std::time::Duration;
253/// use stygian_charon::token_lifecycle::TokenPolicyTable;
254/// use stygian_charon::vendor_classifier::VendorId;
255///
256/// let mut table = TokenPolicyTable::with_builtin_defaults();
257/// // Override Cloudflare to a stricter 5-minute default TTL.
258/// let tighter = table.policy(VendorId::Cloudflare).with_default_ttl(Duration::from_mins(5));
259/// table = table.with_policy(VendorId::Cloudflare, tighter);
260/// assert_eq!(table.policy(VendorId::Cloudflare).default_ttl(), Duration::from_mins(5));
261/// ```
262#[derive(Debug, Clone, Default)]
263pub struct TokenPolicyTable {
264 overrides: BTreeMap<VendorId, TokenPolicy>,
265}
266
267impl TokenPolicyTable {
268 /// Build an empty table (no overrides; every lookup returns
269 /// the [`TokenPolicy::default_for`] baseline).
270 ///
271 /// # Example
272 ///
273 /// ```
274 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
275 /// use stygian_charon::vendor_classifier::VendorId;
276 ///
277 /// let table = TokenPolicyTable::empty();
278 /// assert!(table.is_empty());
279 /// ```
280 #[must_use]
281 pub fn empty() -> Self {
282 Self::default()
283 }
284
285 /// Build a table seeded with the per-vendor defaults for
286 /// every [`VendorId`] variant. The `Unknown` vendor is
287 /// always included as the catch-all fallback.
288 ///
289 /// # Example
290 ///
291 /// ```
292 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
293 /// use stygian_charon::vendor_classifier::VendorId;
294 ///
295 /// let table = TokenPolicyTable::with_builtin_defaults();
296 /// assert!(!table.is_empty());
297 /// assert!(table.contains(VendorId::Cloudflare));
298 /// assert!(table.contains(VendorId::DataDome));
299 /// assert!(table.contains(VendorId::Unknown));
300 /// ```
301 #[must_use]
302 pub fn with_builtin_defaults() -> Self {
303 let mut overrides = BTreeMap::new();
304 for vendor in builtin_token_policies() {
305 overrides.insert(vendor.0, vendor.1);
306 }
307 Self { overrides }
308 }
309
310 /// `true` when the table has no overrides registered.
311 #[must_use]
312 pub fn is_empty(&self) -> bool {
313 self.overrides.is_empty()
314 }
315
316 /// `true` when the table has an override registered for
317 /// `vendor`.
318 ///
319 /// # Example
320 ///
321 /// ```
322 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
323 /// use stygian_charon::vendor_classifier::VendorId;
324 ///
325 /// let table = TokenPolicyTable::with_builtin_defaults();
326 /// assert!(table.contains(VendorId::Cloudflare));
327 /// ```
328 #[must_use]
329 pub fn contains(&self, vendor: VendorId) -> bool {
330 self.overrides.contains_key(&vendor)
331 }
332
333 /// Number of vendors currently registered (including the
334 /// `Unknown` fallback).
335 #[must_use]
336 pub fn len(&self) -> usize {
337 self.overrides.len()
338 }
339
340 /// Per-vendor policy. Returns the override if one is
341 /// registered, otherwise the [`TokenPolicy::default_for`]
342 /// baseline for that vendor.
343 ///
344 /// # Example
345 ///
346 /// ```
347 /// use stygian_charon::token_lifecycle::TokenPolicyTable;
348 /// use stygian_charon::vendor_classifier::VendorId;
349 ///
350 /// let table = TokenPolicyTable::with_builtin_defaults();
351 /// let policy = table.policy(VendorId::Akamai);
352 /// assert!(policy.require_session_binding());
353 /// ```
354 #[must_use]
355 pub fn policy(&self, vendor: VendorId) -> TokenPolicy {
356 self.overrides
357 .get(&vendor)
358 .copied()
359 .unwrap_or_else(|| TokenPolicy::default_for(vendor))
360 }
361
362 /// Register an override for `vendor`. The override
363 /// **replaces** any existing entry.
364 ///
365 /// # Example
366 ///
367 /// ```
368 /// use std::time::Duration;
369 /// use stygian_charon::token_lifecycle::{TokenPolicy, TokenPolicyTable};
370 /// use stygian_charon::vendor_classifier::VendorId;
371 ///
372 /// let mut table = TokenPolicyTable::with_builtin_defaults();
373 /// let override_policy = TokenPolicy::new(
374 /// Duration::from_mins(1),
375 /// Duration::from_mins(2),
376 /// true,
377 /// true,
378 /// true,
379 /// );
380 /// table = table.with_policy(VendorId::PerimeterX, override_policy);
381 /// assert_eq!(table.policy(VendorId::PerimeterX).default_ttl(), Duration::from_mins(1));
382 /// ```
383 #[must_use]
384 pub fn with_policy(mut self, vendor: VendorId, policy: TokenPolicy) -> Self {
385 self.overrides.insert(vendor, policy);
386 self
387 }
388
389 /// Ids of every vendor currently registered (including the
390 /// `Unknown` fallback when present).
391 #[must_use]
392 pub fn vendors(&self) -> Vec<VendorId> {
393 self.overrides.keys().copied().collect()
394 }
395}
396
397/// Snapshot of the built-in per-vendor policy table.
398///
399/// Returns `(vendor, policy)` pairs in [`VendorId`] discriminant
400/// order so the JSON form is byte-stable. Used by
401/// [`TokenPolicyTable::with_builtin_defaults`] and by the
402/// compile-time validation in
403/// `compile_check_builtin_token_policies`.
404///
405/// # Example
406///
407/// ```
408/// use stygian_charon::token_lifecycle::builtin_token_policies;
409///
410/// let rows = builtin_token_policies();
411/// assert!(rows.iter().any(|(v, _)| *v == stygian_charon::vendor_classifier::VendorId::Cloudflare));
412/// ```
413#[must_use]
414pub fn builtin_token_policies() -> Vec<(VendorId, TokenPolicy)> {
415 [
416 VendorId::Akamai,
417 VendorId::Cloudflare,
418 VendorId::DataDome,
419 VendorId::PerimeterX,
420 VendorId::Hcaptcha,
421 VendorId::Recaptcha,
422 VendorId::Kasada,
423 VendorId::FingerprintCom,
424 VendorId::ShapeSecurity,
425 VendorId::Imperva,
426 VendorId::Unknown,
427 ]
428 .iter()
429 .map(|v| (*v, TokenPolicy::default_for(*v)))
430 .collect()
431}
432
433/// Compile-time guarantee that every baseline policy the
434/// built-in table seeds is well-formed.
435///
436/// Used by the
437/// `compile_check_builtin_token_policies` test in the module
438/// tests block below.
439#[doc(hidden)]
440#[allow(dead_code)]
441pub fn compile_check_builtin_token_policies() {
442 for (vendor, policy) in builtin_token_policies() {
443 assert!(policy.max_ttl() >= policy.default_ttl());
444 let _ = vendor;
445 }
446}
447
448#[cfg(test)]
449#[allow(
450 clippy::unwrap_used,
451 clippy::expect_used,
452 clippy::panic,
453 clippy::indexing_slicing
454)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn token_policy_clamps_default_ttl_to_max_ttl() {
460 let policy = TokenPolicy::new(
461 Duration::from_hours(1),
462 Duration::from_mins(10),
463 true,
464 true,
465 false,
466 );
467 assert_eq!(policy.default_ttl(), Duration::from_mins(10));
468 assert_eq!(policy.max_ttl(), Duration::from_mins(10));
469 }
470
471 #[test]
472 fn vendor_default_policies_match_module_table() {
473 assert_eq!(
474 TokenPolicy::default_for(VendorId::Cloudflare).default_ttl(),
475 Duration::from_mins(30)
476 );
477 assert_eq!(
478 TokenPolicy::default_for(VendorId::Cloudflare).max_ttl(),
479 Duration::from_mins(45)
480 );
481 assert_eq!(
482 TokenPolicy::default_for(VendorId::Akamai).default_ttl(),
483 Duration::from_mins(15)
484 );
485 assert_eq!(
486 TokenPolicy::default_for(VendorId::DataDome).default_ttl(),
487 Duration::from_mins(10)
488 );
489 assert_eq!(
490 TokenPolicy::default_for(VendorId::PerimeterX).default_ttl(),
491 Duration::from_mins(15)
492 );
493 assert_eq!(
494 TokenPolicy::default_for(VendorId::Hcaptcha).default_ttl(),
495 Duration::from_mins(5)
496 );
497 assert_eq!(
498 TokenPolicy::default_for(VendorId::Recaptcha).default_ttl(),
499 Duration::from_mins(5)
500 );
501 assert_eq!(
502 TokenPolicy::default_for(VendorId::Kasada).default_ttl(),
503 Duration::from_mins(5)
504 );
505 assert_eq!(
506 TokenPolicy::default_for(VendorId::FingerprintCom).default_ttl(),
507 Duration::from_hours(1)
508 );
509 assert_eq!(
510 TokenPolicy::default_for(VendorId::ShapeSecurity).default_ttl(),
511 Duration::from_mins(10)
512 );
513 assert_eq!(
514 TokenPolicy::default_for(VendorId::Imperva).default_ttl(),
515 Duration::from_mins(15)
516 );
517 assert_eq!(
518 TokenPolicy::default_for(VendorId::Unknown).default_ttl(),
519 Duration::from_mins(5)
520 );
521 }
522
523 #[test]
524 fn default_for_includes_required_session_binding_for_tier2() {
525 // Tier 2 vendors require session binding by default.
526 assert!(TokenPolicy::default_for(VendorId::DataDome).require_session_binding());
527 assert!(TokenPolicy::default_for(VendorId::PerimeterX).require_session_binding());
528 assert!(TokenPolicy::default_for(VendorId::Akamai).require_session_binding());
529 // Tier 1 / fingerprint vendors do not.
530 assert!(!TokenPolicy::default_for(VendorId::Cloudflare).require_session_binding());
531 assert!(!TokenPolicy::default_for(VendorId::Hcaptcha).require_session_binding());
532 assert!(!TokenPolicy::default_for(VendorId::FingerprintCom).require_session_binding());
533 }
534
535 #[test]
536 fn builtin_policies_cover_every_vendor_in_taxonomy() {
537 let rows = builtin_token_policies();
538 assert!(rows.iter().any(|(v, _)| *v == VendorId::Unknown));
539 assert_eq!(rows.len(), 11);
540 }
541
542 #[test]
543 fn compile_check_builtin_token_policies_passes_for_builtins() {
544 // Re-run the runtime compile-check helper to make sure
545 // it executes end-to-end against the built-in table.
546 compile_check_builtin_token_policies();
547 }
548
549 #[test]
550 fn policy_table_lookup_returns_override_or_default() {
551 let mut table = TokenPolicyTable::empty();
552 // Empty table: lookups return TokenPolicy::default_for().
553 assert_eq!(
554 table.policy(VendorId::Cloudflare).default_ttl(),
555 TokenPolicy::default_for(VendorId::Cloudflare).default_ttl()
556 );
557
558 // Register an override.
559 let override_policy = TokenPolicy::new(
560 Duration::from_mins(1),
561 Duration::from_mins(2),
562 true,
563 true,
564 true,
565 );
566 table = table.with_policy(VendorId::Cloudflare, override_policy);
567 assert_eq!(
568 table.policy(VendorId::Cloudflare).default_ttl(),
569 Duration::from_mins(1)
570 );
571 // Non-overridden vendor still returns the baseline.
572 assert_eq!(
573 table.policy(VendorId::DataDome).default_ttl(),
574 TokenPolicy::default_for(VendorId::DataDome).default_ttl()
575 );
576 }
577
578 #[test]
579 fn with_builtin_defaults_seeds_every_vendor() {
580 let table = TokenPolicyTable::with_builtin_defaults();
581 for vendor in [
582 VendorId::Akamai,
583 VendorId::Cloudflare,
584 VendorId::DataDome,
585 VendorId::PerimeterX,
586 VendorId::Hcaptcha,
587 VendorId::Recaptcha,
588 VendorId::Kasada,
589 VendorId::FingerprintCom,
590 VendorId::ShapeSecurity,
591 VendorId::Imperva,
592 VendorId::Unknown,
593 ] {
594 assert!(
595 table.contains(vendor),
596 "missing builtin policy for {vendor:?}"
597 );
598 }
599 assert!(!table.is_empty());
600 }
601
602 #[test]
603 fn policy_table_is_additive_after_override() {
604 let table = TokenPolicyTable::with_builtin_defaults().with_policy(
605 VendorId::Cloudflare,
606 TokenPolicy::new(
607 Duration::from_mins(1),
608 Duration::from_mins(2),
609 true,
610 true,
611 true,
612 ),
613 );
614 // Cloudflare override applied.
615 assert_eq!(
616 table.policy(VendorId::Cloudflare).default_ttl(),
617 Duration::from_mins(1)
618 );
619 // Akamai untouched.
620 assert_eq!(
621 table.policy(VendorId::Akamai).default_ttl(),
622 Duration::from_mins(15)
623 );
624 }
625}