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 | 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}