Skip to main content

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}