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}