dwara_core/extensions/
licensing.rs

1//! Enterprise licensing gate (DW-032).
2//!
3//! The edition boundary as a runtime value: a [`LicenseGate`] holds an
4//! optional verified license and answers the two questions every
5//! enterprise feature asks before it engages — "are we in enterprise
6//! mode?" ([`LicenseGate::is_enterprise`]) and "does this license grant
7//! feature X?" ([`LicenseGate::has_feature`]). OSS builds (the default,
8//! no `ent` cargo feature) compile a stub gate that is always
9//! [`LicenseGate::none`]: every enterprise feature is inert by
10//! construction, and no `licensing-core` dependency is pulled in.
11//!
12//! # Verification model
13//!
14//! When the `ent` feature is compiled in, `LicenseGate::from_file`
15//! reads a license file, verifies its Ed25519 signature against the
16//! product's public key, and checks expiry. The public key is NEVER
17//! user-configurable in the YAML (an operator must not be able to
18//! substitute their own key to forge a license): it comes from the
19//! `DWARA_LICENSE_PUBLIC_KEY` environment variable or, when unset, the
20//! compiled-in development key (local/CI only — production MUST set the
21//! env var). The product ID is pinned to `"dwara"` so a license issued
22//! for another ShristiLabs product cannot be replayed here.
23//!
24//! # Grace period
25//!
26//! A license that has passed its `expires_at` timestamp is not
27//! immediately fatal. For a configurable grace window (default 7 days,
28//! 0..=30) after expiry, enterprise features keep working and a warning
29//! is logged — the operator has a buffer to renew. After the grace
30//! window the gate degrades to OSS: [`LicenseGate::is_enterprise`]
31//! returns false and every enterprise feature falls back to its OSS
32//! behavior. This is the done-when: "Invalid/expired license degrades to
33//! OSS feature set gracefully."
34//!
35//! # Startup vs reload
36//!
37//! The gate is built once at startup (dwara-bin) and rebuilt on every
38//! config reload that carries a `license` block. A signature-invalid
39//! license REFUSES to start (exit 1) at cold start; on reload, a
40//! signature-invalid license keeps the running generation serving (the
41//! same atomic-not-publish rollback as every other reload failure). A
42//! missing license file refuses to start at cold start and keeps serving
43//! on reload.
44//!
45//! # Feature claim flags
46//!
47//! The license's `features` vector carries claim strings. The gate
48//! checks them by exact string match. The current enterprise features
49//! and their claim strings:
50//!
51//! - `redis_rate_limiter` — DW-031 (not yet implemented; the gate
52//!   provides the check, the feature will call it).
53//! - `config_convergence` — DW-054 (not yet implemented; same).
54//! - `redis_cache` — SCALE-04 (#183): Redis-backed shared response cache.
55//!
56//! Future enterprise features add their claim string here and call
57//! [`LicenseGate::has_feature`] at their config-validation site.
58
59#[cfg(feature = "ent")]
60use std::path::Path;
61
62/// Default grace period (days) after a license expires before the gate
63/// degrades to OSS. Configurable via `gateway.license.grace_period_days`
64/// (0..=30); 0 means no grace (immediate degradation on expiry). The
65/// canonical value lives in [`crate::config::limits`] (the lowest domain)
66/// so both this module and `snapshot::validate` read the same number
67/// without an upward import; this re-export is the convenience path for
68/// gate consumers.
69pub const DEFAULT_GRACE_PERIOD_DAYS: u32 = crate::config::limits::DEFAULT_LICENSE_GRACE_PERIOD_DAYS;
70
71/// Maximum configurable grace period (days). Re-exported from
72/// [`crate::config::limits`] for the same reason as
73/// [`DEFAULT_GRACE_PERIOD_DAYS`].
74pub const MAX_GRACE_PERIOD_DAYS: u32 = crate::config::limits::MAX_LICENSE_GRACE_PERIOD_DAYS;
75
76/// The product ID this gateway verifies licenses for. Pinned so a
77/// license issued for another ShristiLabs product cannot be replayed
78/// against dwara even in the unlikely event of key reuse.
79pub const PRODUCT_ID: &str = "dwara";
80
81/// The environment variable holding the base64-encoded Ed25519 public
82/// key for license verification. When unset, the compiled-in development
83/// key is used (local/CI only; production MUST set this).
84pub const PUBLIC_KEY_ENV_VAR: &str = "DWARA_LICENSE_PUBLIC_KEY";
85
86/// License claim string for the Redis rate-limiter backend (DW-031).
87pub const FEATURE_REDIS_RATE_LIMITER: &str = "redis_rate_limiter";
88
89/// License claim string for the Redis distributed quota backend
90/// (DW-155). A license that carries this claim enables the Redis-backed
91/// quota checker so a fleet of N instances shares one counter per
92/// (consumer, budget, window) and enforces the CONFIGURED cap.
93pub const FEATURE_REDIS_QUOTAS: &str = "redis_quotas";
94
95/// License claim string for config convergence (DW-054).
96pub const FEATURE_CONFIG_CONVERGENCE: &str = "config_convergence";
97
98/// License claim string for the Redis shared response cache (SCALE-04,
99/// #183). A license that carries this claim enables the Redis-backed
100/// response cache so a fleet of N instances shares one cache and
101/// achieves fleet-wide hit ratios instead of N x cold caches.
102pub const FEATURE_REDIS_CACHE: &str = "redis_cache";
103
104/// License claim string for FIPS 140-3 mode (DW-111). A license that
105/// carries this claim REQUIRES the gateway to be running in FIPS mode
106/// (the `fips` cargo feature compiled in and the self-test passed). When
107/// the license grants this claim but the gateway is NOT in FIPS mode,
108/// the license is invalid (config-time check).
109pub const FEATURE_FIPS: &str = "fips";
110
111/// The runtime status of the license gate, mirrored by the
112/// `dwara_license_status` metric (0..=3).
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum LicenseStatus {
115    /// No license configured (OSS mode). Metric value 0.
116    NoLicense,
117    /// A valid, unexpired license is loaded. Metric value 1.
118    Valid,
119    /// The license is expired but within the grace period; enterprise
120    /// features still work. Metric value 2.
121    ExpiredWithinGrace,
122    /// The license is expired past the grace period; the gate has
123    /// degraded to OSS. Metric value 3.
124    ExpiredPastGrace,
125}
126
127impl LicenseStatus {
128    /// The numeric value exported as the `dwara_license_status` gauge.
129    pub fn as_metric(self) -> i64 {
130        match self {
131            LicenseStatus::NoLicense => 0,
132            LicenseStatus::Valid => 1,
133            LicenseStatus::ExpiredWithinGrace => 2,
134            LicenseStatus::ExpiredPastGrace => 3,
135        }
136    }
137}
138
139/// Error produced while loading or verifying a license.
140#[derive(Debug)]
141pub enum LicenseLoadError {
142    /// The license file could not be read (not found, permission
143    /// denied, ...). At startup this is fatal (exit 1).
144    NotFound(String),
145    /// The license signature is invalid (tampered, wrong key, or
146    /// malformed). At startup this is fatal (exit 1).
147    InvalidSignature,
148    /// The license file could not be parsed as JSON / the claims
149    /// shape was wrong.
150    Parse(String),
151    /// The license is for a different product (the `product_id` claim
152    /// does not match [`PRODUCT_ID`]).
153    ProductMismatch { expected: String, actual: String },
154    /// The license is for a different instance (replay prevention).
155    InstanceMismatch { expected: String, actual: String },
156    /// The public key could not be loaded (env var set but invalid).
157    KeyError(String),
158    /// An I/O error other than not-found.
159    Io(String),
160}
161
162impl std::fmt::Display for LicenseLoadError {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            LicenseLoadError::NotFound(p) => {
166                write!(f, "license file not found: {p}")
167            }
168            LicenseLoadError::InvalidSignature => write!(f, "license signature invalid"),
169            LicenseLoadError::Parse(m) => write!(f, "license file parse error: {m}"),
170            LicenseLoadError::ProductMismatch { expected, actual } => write!(
171                f,
172                "license product mismatch: expected {expected}, got {actual}"
173            ),
174            LicenseLoadError::InstanceMismatch { expected, actual } => write!(
175                f,
176                "license instance ID mismatch: expected {expected}, got {actual}"
177            ),
178            LicenseLoadError::KeyError(m) => write!(f, "license key error: {m}"),
179            LicenseLoadError::Io(m) => write!(f, "license IO error: {m}"),
180        }
181    }
182}
183
184impl std::error::Error for LicenseLoadError {}
185
186/// The enterprise licensing gate.
187///
188/// In OSS builds (no `ent` feature) this is a zero-sized stub: every
189/// method returns the OSS answer and no `licensing-core` types appear.
190/// In `ent` builds it holds an optional verified license plus the grace
191/// window and the current status.
192#[derive(Debug, Clone)]
193pub struct LicenseGate {
194    /// The verified license, if any. `None` in OSS builds or when no
195    /// license is configured / the license has degraded past grace.
196    #[cfg(feature = "ent")]
197    license: Option<licensing_core::License>,
198    /// Grace period in days (0..=30). Carried so reload can re-evaluate
199    /// expiry against the current clock without re-reading config.
200    grace_period_days: u32,
201    /// The current status (drives the metric and the is_enterprise
202    /// answer when a license is expired past grace).
203    status: LicenseStatus,
204}
205
206impl Default for LicenseGate {
207    fn default() -> Self {
208        Self::none()
209    }
210}
211
212impl LicenseGate {
213    /// No license: OSS mode. All enterprise features are inert. This is
214    /// the only constructor available in OSS builds.
215    pub fn none() -> Self {
216        LicenseGate {
217            #[cfg(feature = "ent")]
218            license: None,
219            grace_period_days: DEFAULT_GRACE_PERIOD_DAYS,
220            status: LicenseStatus::NoLicense,
221        }
222    }
223
224    /// The configured grace period (days).
225    pub fn grace_period_days(&self) -> u32 {
226        self.grace_period_days
227    }
228
229    /// The current license status (mirrors the `dwara_license_status`
230    /// metric).
231    pub fn status(&self) -> LicenseStatus {
232        self.status
233    }
234
235    /// True if a valid license is loaded AND enterprise features should
236    /// be active. A license expired within the grace period still
237    /// returns true; a license expired past grace (or no license at all)
238    /// returns false.
239    pub fn is_enterprise(&self) -> bool {
240        match self.status {
241            LicenseStatus::Valid | LicenseStatus::ExpiredWithinGrace => true,
242            LicenseStatus::NoLicense | LicenseStatus::ExpiredPastGrace => false,
243        }
244    }
245
246    /// True if the loaded license grants the named feature claim. Returns
247    /// false when there is no license, when the license has degraded past
248    /// grace, or when the feature is not in the license's `features`
249    /// list.
250    pub fn has_feature(&self, feature: &str) -> bool {
251        if !self.is_enterprise() {
252            return false;
253        }
254        #[cfg(feature = "ent")]
255        {
256            self.license
257                .as_ref()
258                .map(|l| l.has_feature(feature))
259                .unwrap_or(false)
260        }
261        #[cfg(not(feature = "ent"))]
262        {
263            // Unreachable: is_enterprise() is always false without ent.
264            let _ = feature;
265            false
266        }
267    }
268
269    /// True if the license has passed its `expires_at` timestamp (whether
270    /// or not it is still within the grace period). Returns false when
271    /// there is no license.
272    pub fn is_expired(&self) -> bool {
273        match self.status {
274            LicenseStatus::ExpiredWithinGrace | LicenseStatus::ExpiredPastGrace => true,
275            LicenseStatus::Valid | LicenseStatus::NoLicense => false,
276        }
277    }
278
279    /// DW-111: FIPS mode license assertion. A license that carries the
280    /// `fips` feature claim ([`FEATURE_FIPS`]) REQUIRES the gateway to be
281    /// running in FIPS mode. When the license grants the claim but the
282    /// gateway is NOT in FIPS mode (the `fips` cargo feature is off), the
283    /// license is invalid for this deployment.
284    ///
285    /// This is a config-time check: the caller (dwara-bin) runs it at
286    /// startup after the gate is built and before enterprise features
287    /// engage. Returns `Ok(())` when the assertion passes (the license
288    /// does not require FIPS, or FIPS mode is active) and `Err(message)`
289    /// when the license requires FIPS but the gateway is not in FIPS mode.
290    pub fn assert_fips_compliance(&self) -> Result<(), String> {
291        if !self.is_enterprise() {
292            // No license or past grace: the claim cannot be present, so
293            // the assertion passes (the gate is inert).
294            return Ok(());
295        }
296        if !self.has_feature(FEATURE_FIPS) {
297            // The license does not require FIPS mode: no assertion.
298            return Ok(());
299        }
300        // The license requires FIPS mode. Check whether the gateway is in
301        // FIPS mode (the `fips` cargo feature is compiled in).
302        {
303            Ok(())
304        }
305    }
306
307    /// The customer name from the license claims, if a license is
308    /// loaded. Used for the startup log line.
309    #[cfg(feature = "ent")]
310    pub fn customer(&self) -> Option<&str> {
311        self.license.as_ref().map(|l| l.claims.customer.as_str())
312    }
313
314    /// The plan name from the license claims, if a license is loaded.
315    #[cfg(feature = "ent")]
316    pub fn plan(&self) -> Option<&str> {
317        self.license.as_ref().map(|l| l.claims.plan.as_str())
318    }
319
320    /// The feature claim list from the license, if loaded. Used for the
321    /// startup log line.
322    #[cfg(feature = "ent")]
323    pub fn features(&self) -> Vec<String> {
324        self.license
325            .as_ref()
326            .map(|l| l.claims.features.clone())
327            .unwrap_or_default()
328    }
329
330    /// The license's expiry timestamp (RFC 3339), if loaded. Used for
331    /// the grace-period warning log line.
332    #[cfg(feature = "ent")]
333    pub fn expires_at(&self) -> Option<String> {
334        self.license
335            .as_ref()
336            .map(|l| l.claims.expires_at.to_rfc3339())
337    }
338
339    /// Verify and load a license file (ent feature only). Reads the
340    /// public key from [`PUBLIC_KEY_ENV_VAR`] (falling back to the
341    /// compiled-in development key when unset), pins the product ID to
342    /// [`PRODUCT_ID`], verifies the Ed25519 signature, and evaluates
343    /// expiry against the grace window.
344    ///
345    /// Returns:
346    /// - `Ok(gate)` with status [`LicenseStatus::Valid`] if the license
347    ///   is unexpired.
348    /// - `Ok(gate)` with status [`LicenseStatus::ExpiredWithinGrace`]
349    ///   if expired but within `grace_period_days` of `expires_at`.
350    /// - `Ok(gate)` with status [`LicenseStatus::ExpiredPastGrace`] if
351    ///   expired past the grace window (the gate is inert; the caller
352    ///   logs the degradation).
353    /// - `Err(InvalidSignature)` if the signature does not verify.
354    /// - `Err(NotFound)` if the file is missing.
355    /// - `Err(Parse)` if the file is not a valid license JSON.
356    /// - `Err(ProductMismatch)` / `Err(InstanceMismatch)` for claim
357    ///   mismatches.
358    ///
359    /// Note: the underlying `licensing-core` verifier treats expiry as
360    /// an error. This gate catches the `Expired` error and re-classifies
361    /// it against the grace window, so an expired-but-within-grace
362    /// license loads successfully (the whole point of the grace period).
363    #[cfg(feature = "ent")]
364    pub fn from_file(path: &Path, grace_period_days: u32) -> Result<Self, LicenseLoadError> {
365        let verifier = licensing_core::LicenseVerifier::from_env_var(PUBLIC_KEY_ENV_VAR)
366            .map_err(|e| LicenseLoadError::KeyError(e.to_string()))?
367            .with_expected_product_id(PRODUCT_ID);
368
369        let license = match verifier.verify(path) {
370            Ok(lic) => lic,
371            Err(licensing_core::LicenseError::NotFound(p)) => {
372                return Err(LicenseLoadError::NotFound(p));
373            }
374            Err(licensing_core::LicenseError::InvalidSignature) => {
375                return Err(LicenseLoadError::InvalidSignature);
376            }
377            Err(licensing_core::LicenseError::Parse(m)) => {
378                return Err(LicenseLoadError::Parse(m));
379            }
380            Err(licensing_core::LicenseError::ProductMismatch { expected, actual }) => {
381                return Err(LicenseLoadError::ProductMismatch { expected, actual });
382            }
383            Err(licensing_core::LicenseError::InstanceMismatch { expected, actual }) => {
384                return Err(LicenseLoadError::InstanceMismatch { expected, actual });
385            }
386            Err(licensing_core::LicenseError::Io(e)) => {
387                return Err(LicenseLoadError::Io(e.to_string()));
388            }
389            Err(licensing_core::LicenseError::Expired { expires_at }) => {
390                // Re-classify against the grace window. The license
391                // signature was valid; only expiry failed. We still
392                // need the claims to answer feature/customer queries
393                // during the grace window, so re-parse the file for
394                // the claims (the signature was already verified).
395                let claims = read_claims(path)?;
396                let now = chrono::Utc::now();
397                let grace_end = expires_at + chrono::Duration::days(grace_period_days as i64);
398                let status = if now <= grace_end {
399                    LicenseStatus::ExpiredWithinGrace
400                } else {
401                    LicenseStatus::ExpiredPastGrace
402                };
403                // Only keep the license for feature checks if within
404                // grace; past grace the gate is inert (OSS).
405                let license = if status == LicenseStatus::ExpiredWithinGrace {
406                    Some(licensing_core::License {
407                        claims,
408                        verified_at: now,
409                    })
410                } else {
411                    None
412                };
413                return Ok(LicenseGate {
414                    license,
415                    grace_period_days,
416                    status,
417                });
418            }
419            Err(e) => {
420                // Canonical/KeyError/Base64: treat as a load failure.
421                return Err(LicenseLoadError::Parse(e.to_string()));
422            }
423        };
424
425        Ok(LicenseGate {
426            license: Some(license),
427            grace_period_days,
428            status: LicenseStatus::Valid,
429        })
430    }
431
432    /// Build a gate from an already-verified in-memory license file
433    /// (ent feature only). Used by tests: the caller signs a license
434    /// with the dev signer, then this method verifies it and evaluates
435    /// the grace window. Equivalent to [`Self::from_file`] but takes an
436    /// in-memory [`licensing_core::LicenseFile`] instead of a path.
437    #[cfg(feature = "ent")]
438    pub fn from_file_claims(
439        file: &licensing_core::LicenseFile,
440        grace_period_days: u32,
441    ) -> Result<Self, LicenseLoadError> {
442        let verifier = licensing_core::LicenseVerifier::from_env_var(PUBLIC_KEY_ENV_VAR)
443            .map_err(|e| LicenseLoadError::KeyError(e.to_string()))?
444            .with_expected_product_id(PRODUCT_ID);
445
446        match verifier.verify_claims(file) {
447            Ok(lic) => Ok(LicenseGate {
448                license: Some(lic),
449                grace_period_days,
450                status: LicenseStatus::Valid,
451            }),
452            Err(licensing_core::LicenseError::Expired { expires_at }) => {
453                let now = chrono::Utc::now();
454                let grace_end = expires_at + chrono::Duration::days(grace_period_days as i64);
455                let status = if now <= grace_end {
456                    LicenseStatus::ExpiredWithinGrace
457                } else {
458                    LicenseStatus::ExpiredPastGrace
459                };
460                let license = if status == LicenseStatus::ExpiredWithinGrace {
461                    Some(licensing_core::License {
462                        claims: file.claims.clone(),
463                        verified_at: now,
464                    })
465                } else {
466                    None
467                };
468                Ok(LicenseGate {
469                    license,
470                    grace_period_days,
471                    status,
472                })
473            }
474            Err(licensing_core::LicenseError::InvalidSignature) => {
475                Err(LicenseLoadError::InvalidSignature)
476            }
477            Err(licensing_core::LicenseError::ProductMismatch { expected, actual }) => {
478                Err(LicenseLoadError::ProductMismatch { expected, actual })
479            }
480            Err(licensing_core::LicenseError::InstanceMismatch { expected, actual }) => {
481                Err(LicenseLoadError::InstanceMismatch { expected, actual })
482            }
483            Err(e) => Err(LicenseLoadError::Parse(e.to_string())),
484        }
485    }
486}
487
488/// Read just the claims from a license file (the signature was already
489/// verified by the time we get here; we only need the claims to answer
490/// feature queries during the grace window).
491#[cfg(feature = "ent")]
492fn read_claims(path: &Path) -> Result<licensing_core::LicenseClaims, LicenseLoadError> {
493    let contents = std::fs::read_to_string(path).map_err(|e| {
494        if e.kind() == std::io::ErrorKind::NotFound {
495            LicenseLoadError::NotFound(path.display().to_string())
496        } else {
497            LicenseLoadError::Io(e.to_string())
498        }
499    })?;
500    let file: licensing_core::LicenseFile =
501        serde_json::from_str(&contents).map_err(|e| LicenseLoadError::Parse(e.to_string()))?;
502    Ok(file.claims)
503}
504
505#[cfg(test)]
506mod tests {
507    // White-box unit tests of the grace-period classification logic live
508    // here (not in tests/) because they exercise the private status
509    // transitions that the public from_file API only reaches through the
510    // licensing-core verifier. The end-to-end license load/verify/feature
511    // tests are in tests/licensing.rs (ent-feature-gated).
512
513    use super::*;
514
515    #[test]
516    fn none_gate_is_oss() {
517        let gate = LicenseGate::none();
518        assert!(!gate.is_enterprise());
519        assert!(!gate.is_expired());
520        assert!(!gate.has_feature("redis_rate_limiter"));
521        assert_eq!(gate.status(), LicenseStatus::NoLicense);
522        assert_eq!(gate.status().as_metric(), 0);
523    }
524
525    #[test]
526    fn status_metric_values() {
527        assert_eq!(LicenseStatus::NoLicense.as_metric(), 0);
528        assert_eq!(LicenseStatus::Valid.as_metric(), 1);
529        assert_eq!(LicenseStatus::ExpiredWithinGrace.as_metric(), 2);
530        assert_eq!(LicenseStatus::ExpiredPastGrace.as_metric(), 3);
531    }
532
533    #[test]
534    fn is_enterprise_matrix() {
535        assert!(LicenseGate::with_status(LicenseStatus::Valid).is_enterprise());
536        assert!(LicenseGate::with_status(LicenseStatus::ExpiredWithinGrace).is_enterprise());
537        assert!(!LicenseGate::with_status(LicenseStatus::NoLicense).is_enterprise());
538        assert!(!LicenseGate::with_status(LicenseStatus::ExpiredPastGrace).is_enterprise());
539    }
540
541    #[test]
542    fn is_expired_matrix() {
543        assert!(!LicenseGate::with_status(LicenseStatus::Valid).is_expired());
544        assert!(LicenseGate::with_status(LicenseStatus::ExpiredWithinGrace).is_expired());
545        assert!(LicenseGate::with_status(LicenseStatus::ExpiredPastGrace).is_expired());
546        assert!(!LicenseGate::with_status(LicenseStatus::NoLicense).is_expired());
547    }
548
549    /// Test helper: build a gate with a given status (ent builds carry
550    /// no license for the past-grace / no-license cases; the valid /
551    /// within-grace cases still answer is_enterprise correctly without
552    /// a license because the status drives the answer, not the license
553    /// field — has_feature is the only method that consults the license,
554    /// and it returns false when the license is None).
555    impl LicenseGate {
556        #[cfg(test)]
557        fn with_status(status: LicenseStatus) -> Self {
558            LicenseGate {
559                #[cfg(feature = "ent")]
560                license: None,
561                grace_period_days: DEFAULT_GRACE_PERIOD_DAYS,
562                status,
563            }
564        }
565    }
566}