From 297ff4688c05978d3de8e6d057ef7b9a4c197e83 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Tue, 1 Sep 2026 19:31:35 +0800 Subject: [PATCH] feat(license): add entitlement provider abstraction (#7006) --- Cargo.lock | 8 + Cargo.toml | 2 + crates/license/Cargo.toml | 29 ++++ crates/license/src/lib.rs | 133 ++++++++++++++++ rustfs/Cargo.toml | 1 + rustfs/src/license.rs | 326 +++++++++++++++++++++----------------- 6 files changed, 356 insertions(+), 143 deletions(-) create mode 100644 crates/license/Cargo.toml create mode 100644 crates/license/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index e2a9d6d27..03ed0bba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9462,6 +9462,7 @@ dependencies = [ "rustfs-io-metrics", "rustfs-keystone", "rustfs-kms", + "rustfs-license", "rustfs-lock", "rustfs-log-analyzer", "rustfs-madmin", @@ -10064,6 +10065,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustfs-license" +version = "1.0.0-rc.5" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "rustfs-lifecycle" version = "1.0.0-rc.5" diff --git a/Cargo.toml b/Cargo.toml index 4f661dc3e..6d14a275d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/heal-contracts", # Heal request/response channel contracts "crates/iam", # Identity and Access Management "crates/keystone", # OpenStack Keystone integration + "crates/license", # License and entitlement provider contracts "crates/lifecycle", # Lifecycle rule evaluation contracts "crates/kms", # Key Management Service "crates/lock", # Distributed locking implementation @@ -105,6 +106,7 @@ rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.5" } rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.5" } rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.5" } rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.5" } +rustfs-license = { path = "crates/license", version = "1.0.0-rc.5" } rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.5" } rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.5" } rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.5" } diff --git a/crates/license/Cargo.toml b/crates/license/Cargo.toml new file mode 100644 index 000000000..263dbdb67 --- /dev/null +++ b/crates/license/Cargo.toml @@ -0,0 +1,29 @@ +# Copyright 2024 RustFS Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "rustfs-license" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +homepage.workspace = true +description = "License and entitlement provider contracts for RustFS" + +[lints] +workspace = true + +[dependencies] +thiserror = { workspace = true } diff --git a/crates/license/src/lib.rs b/crates/license/src/lib.rs new file mode 100644 index 000000000..4c8736138 --- /dev/null +++ b/crates/license/src/lib.rs @@ -0,0 +1,133 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Provider-neutral license and entitlement contracts. + +use std::{fmt, sync::Arc}; + +use thiserror::Error; + +pub type LicenseResult = Result; +pub type SharedLicenseProvider = Arc; + +/// Entitlement required by the existing server-wide license gate. +pub const SERVER_ENTITLEMENT: &str = "rustfs.server"; + +/// Provider-neutral metadata exposed through existing RustFS status APIs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LicenseMetadata { + pub subject: String, + pub expires_at: Option, +} + +/// Sanitized provider state suitable for status and diagnostics output. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum LicenseStatus { + #[default] + Uninitialized, + Valid, + Missing, + Invalid(String), + Unavailable, +} + +impl fmt::Display for LicenseStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Uninitialized => write!(f, "uninitialized"), + Self::Valid => write!(f, "valid"), + Self::Missing => write!(f, "missing"), + Self::Invalid(message) => write!(f, "{message}"), + Self::Unavailable => write!(f, "unavailable"), + } + } +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum LicenseError { + #[error("License state is unavailable")] + StatePoisoned, + #[error("License is required when building with feature `license`.")] + Missing, + #[error("Incorrect license, please contact RustFS. {0}")] + Invalid(String), + #[error("Incorrect license, please contact RustFS. expired_at={expired_at}, now={now}")] + Expired { expired_at: u64, now: u64 }, + #[error("Failed to read system time: {0}")] + Clock(String), + #[error("Entitlement is not granted: {entitlement}")] + Denied { entitlement: String }, + #[error("License provider is unavailable: {0}")] + Unavailable(String), + #[error("Entitlement identifier is empty or not normalized")] + InvalidEntitlement, +} + +/// Runtime boundary between RustFS and a license implementation. +/// +/// Providers must sanitize all strings returned in errors, status, and +/// metadata. In particular, they must never include raw license material. +/// `initialize` must be safe to call repeatedly with the same input. `check` +/// must be idempotent and must not consume quota, acquire a lease, or mutate +/// external state. Providers that require a license must fail closed before +/// successful initialization. +pub trait LicenseProvider: Send + Sync { + fn initialize(&self, raw_license: Option<&str>) -> LicenseResult<()>; + + fn check(&self, entitlement: &str) -> LicenseResult<()>; + + fn status(&self) -> LicenseStatus; + + fn metadata(&self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct TestProvider; + + impl LicenseProvider for TestProvider { + fn initialize(&self, _raw_license: Option<&str>) -> LicenseResult<()> { + Ok(()) + } + + fn check(&self, _entitlement: &str) -> LicenseResult<()> { + Ok(()) + } + + fn status(&self) -> LicenseStatus { + LicenseStatus::Valid + } + } + + #[test] + fn provider_is_object_safe() { + let provider: SharedLicenseProvider = Arc::new(TestProvider); + + assert_eq!(provider.status(), LicenseStatus::Valid); + } + + #[test] + fn status_display_is_stable() { + assert_eq!(LicenseStatus::Uninitialized.to_string(), "uninitialized"); + assert_eq!(LicenseStatus::Valid.to_string(), "valid"); + assert_eq!(LicenseStatus::Missing.to_string(), "missing"); + assert_eq!(LicenseStatus::Invalid("invalid key".to_string()).to_string(), "invalid key"); + assert_eq!(LicenseStatus::Unavailable.to_string(), "unavailable"); + } +} diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index 529fc4b73..73db55970 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -235,6 +235,7 @@ rustfs-ecstore = { workspace = true } rustfs-filemeta.workspace = true rustfs-iam = { workspace = true } rustfs-keystone = { workspace = true } +rustfs-license = { workspace = true } rustfs-kms = { workspace = true } rustfs-lock.workspace = true rustfs-log-analyzer = { workspace = true } diff --git a/rustfs/src/license.rs b/rustfs/src/license.rs index 75638eaeb..c2dcc7842 100644 --- a/rustfs/src/license.rs +++ b/rustfs/src/license.rs @@ -13,7 +13,9 @@ // limitations under the License. use rustfs_crypto::{Token, parse_license_with_public_key}; -use std::fmt; +pub use rustfs_license::{ + LicenseError, LicenseMetadata, LicenseProvider, LicenseResult, LicenseStatus, SERVER_ENTITLEMENT, SharedLicenseProvider, +}; use std::io::{Error, ErrorKind, Result}; use std::sync::Arc; use std::sync::OnceLock; @@ -25,83 +27,18 @@ const LOG_COMPONENT_LICENSE: &str = "license"; const LOG_SUBSYSTEM_RUNTIME: &str = "runtime"; const EVENT_LICENSE_INITIALIZATION_FAILED: &str = "license_initialization_failed"; -pub type LicenseResult = std::result::Result; pub type SharedLicenseVerifier = Arc; -#[derive(Clone, Debug)] -pub enum LicenseError { - /// Internal license state lock could not be acquired. - StatePoisoned, - /// License is required in licensed builds but not provided. - Missing, - /// Token decoding or signature check failed. - Invalid(String), - /// License expiration check failed. - #[cfg(feature = "license")] - Expired { expired_at: u64, now: u64 }, - /// System time could not be read. - Clock(String), -} - -impl fmt::Display for LicenseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - LicenseError::StatePoisoned => write!(f, "License state is unavailable"), - LicenseError::Missing => write!(f, "License is required when building with feature `license`."), - LicenseError::Invalid(message) => write!(f, "Incorrect license, please contact RustFS. {message}"), - #[cfg(feature = "license")] - LicenseError::Expired { expired_at, now } => { - write!(f, "Incorrect license, please contact RustFS. expired_at={expired_at}, now={now}") - } - LicenseError::Clock(message) => write!(f, "Failed to read system time: {message}"), - } - } -} - -impl std::error::Error for LicenseError {} - -impl LicenseError { - fn into_io(self) -> Error { - match self { - LicenseError::StatePoisoned | LicenseError::Clock(_) => Error::other(self.to_string()), - LicenseError::Missing | LicenseError::Invalid(_) => Error::new(ErrorKind::PermissionDenied, self.to_string()), - #[cfg(feature = "license")] - LicenseError::Expired { .. } => Error::new(ErrorKind::PermissionDenied, self.to_string()), - } - } -} - -#[derive(Clone, Debug, Default)] -enum LicenseStatus { - /// Internal state has not been evaluated yet. - #[default] - Uninitialized, - /// License has been validated. - Valid, - /// License is missing for strict license builds. - Missing, - /// License validation failed. - Invalid(String), -} - -impl fmt::Display for LicenseStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Uninitialized => write!(f, "uninitialized"), - Self::Valid => write!(f, "valid"), - Self::Missing => write!(f, "missing"), - Self::Invalid(message) => write!(f, "{message}"), - } - } -} - #[derive(Clone, Debug, Default)] struct LicenseState { token: Option, status: LicenseStatus, } -/// Verifier for parsing and validating raw license materials. +/// Lower-level verifier used by the default open-source provider. +/// +/// OEM integrations that own entitlement decisions should install a +/// [`LicenseProvider`] instead. pub trait LicenseVerifier: Send + Sync { fn validate(&self, raw_license: &str, now: u64) -> LicenseResult; } @@ -129,6 +66,7 @@ impl LicenseVerifier for AppAuthLicenseVerifier { static LICENSE_STATE: OnceLock> = OnceLock::new(); static LICENSE_VERIFIER: OnceLock = OnceLock::new(); +static LICENSE_PROVIDER: OnceLock = OnceLock::new(); fn license_state() -> &'static RwLock { LICENSE_STATE.get_or_init(|| RwLock::new(LicenseState::default())) @@ -173,6 +111,7 @@ fn license_public_key() -> LicenseResult { Ok(public_key) } +#[cfg(test)] fn is_license_token_current(token: &Token, now: u64) -> bool { token.expired > now } @@ -194,11 +133,13 @@ fn apply_invalid_status(state: &mut LicenseState, err: LicenseError) { state.token = None; state.status = LicenseStatus::Invalid(match err { LicenseError::Invalid(message) => message, - #[cfg(feature = "license")] LicenseError::Expired { expired_at, now } => format!("expired at {expired_at}, now {now}"), LicenseError::Clock(message) => format!("system clock error: {message}"), LicenseError::Missing => "license is required".to_string(), LicenseError::StatePoisoned => "license state is unavailable".to_string(), + LicenseError::Denied { entitlement } => format!("entitlement is not granted: {entitlement}"), + LicenseError::Unavailable(message) => format!("provider is unavailable: {message}"), + LicenseError::InvalidEntitlement => "entitlement identifier is invalid".to_string(), }); } @@ -207,21 +148,117 @@ fn apply_valid_status(state: &mut LicenseState, token: Token) { state.status = LicenseStatus::Valid; } -/// Replace the global license verifier. +#[derive(Debug, Default)] +struct OpenSourceLicenseProvider; + +impl LicenseProvider for OpenSourceLicenseProvider { + fn initialize(&self, raw_license: Option<&str>) -> LicenseResult<()> { + let normalized = raw_license.map(str::trim).filter(|raw| !raw.is_empty()); + + let Some(raw_license) = normalized else { + let mut state = license_state().write().map_err(|_| LicenseError::StatePoisoned)?; + apply_missing_status(&mut state); + return if state.status == LicenseStatus::Missing { + Err(LicenseError::Missing) + } else { + Ok(()) + }; + }; + + let now = now_epoch_secs()?; + let result = license_verifier().validate(raw_license, now); + let mut state = license_state().write().map_err(|_| LicenseError::StatePoisoned)?; + match result { + Ok(token) => { + apply_valid_status(&mut state, token); + Ok(()) + } + Err(err) => { + apply_invalid_status(&mut state, err.clone()); + Err(err) + } + } + } + + fn check(&self, _entitlement: &str) -> LicenseResult<()> { + #[cfg(not(feature = "license"))] + return Ok(()); + + #[cfg(feature = "license")] + { + let state = license_state().read().map_err(|_| LicenseError::StatePoisoned)?; + match &state.status { + LicenseStatus::Missing => return Err(LicenseError::Missing), + LicenseStatus::Invalid(message) => return Err(LicenseError::Invalid(message.clone())), + LicenseStatus::Unavailable => { + return Err(LicenseError::Unavailable("license state is unavailable".to_string())); + } + LicenseStatus::Uninitialized | LicenseStatus::Valid => {} + } + + let token = state.token.as_ref().ok_or(LicenseError::Missing)?; + let now = now_epoch_secs()?; + if token.expired <= now { + return Err(LicenseError::Expired { + expired_at: token.expired, + now, + }); + } + + Ok(()) + } + } + + fn status(&self) -> LicenseStatus { + license_state() + .read() + .map(|state| state.status.clone()) + .unwrap_or(LicenseStatus::Unavailable) + } + + fn metadata(&self) -> Option { + license_state().read().ok().and_then(|state| { + state.token.as_ref().map(|token| LicenseMetadata { + subject: token.name.clone(), + expires_at: Some(token.expired), + }) + }) + } +} + +fn default_license_provider() -> SharedLicenseProvider { + Arc::new(OpenSourceLicenseProvider) +} + +fn license_provider() -> &'static SharedLicenseProvider { + LICENSE_PROVIDER.get_or_init(default_license_provider) +} + +/// Replace the verifier used by the default open-source provider. /// -/// This is the extension point for OEM/build-time overlays. /// Returns `false` if the verifier was already initialized. pub fn set_license_verifier(verifier: SharedLicenseVerifier) -> bool { LICENSE_VERIFIER.set(verifier).is_ok() } +/// Replace the global license provider before any other license API is used. +/// +/// Returns `false` if the provider was already initialized. +pub fn set_license_provider(provider: SharedLicenseProvider) -> bool { + LICENSE_PROVIDER.set(provider).is_ok() +} + /// Initialize the license in memory. /// /// This keeps the default API signature stable and is safe to call multiple times. pub fn initialize_license(raw_license: Option) { if let Err(err) = initialize_license_result(raw_license) { match err { - LicenseError::Missing | LicenseError::Invalid(_) => { + LicenseError::Missing + | LicenseError::Invalid(_) + | LicenseError::Expired { .. } + | LicenseError::Denied { .. } + | LicenseError::InvalidEntitlement => { warn!( event = EVENT_LICENSE_INITIALIZATION_FAILED, component = LOG_COMPONENT_LICENSE, @@ -230,17 +267,7 @@ pub fn initialize_license(raw_license: Option) { "License initialization failed" ); } - #[cfg(feature = "license")] - LicenseError::Expired { .. } => { - warn!( - event = EVENT_LICENSE_INITIALIZATION_FAILED, - component = LOG_COMPONENT_LICENSE, - subsystem = LOG_SUBSYSTEM_RUNTIME, - error = %err, - "License initialization failed" - ); - } - LicenseError::StatePoisoned | LicenseError::Clock(_) => { + LicenseError::StatePoisoned | LicenseError::Clock(_) | LicenseError::Unavailable(_) => { error!( event = EVENT_LICENSE_INITIALIZATION_FAILED, component = LOG_COMPONENT_LICENSE, @@ -256,31 +283,7 @@ pub fn initialize_license(raw_license: Option) { /// Explicit initialization API with typed error return. pub fn initialize_license_result(raw_license: Option) -> LicenseResult<()> { let normalized = normalized_license(raw_license); - let mut state = license_state().write().map_err(|_| LicenseError::StatePoisoned)?; - - match normalized { - Some(raw_license) => { - let now = now_epoch_secs()?; - match license_verifier().validate(&raw_license, now) { - Ok(token) => { - apply_valid_status(&mut state, token); - Ok(()) - } - Err(err) => { - apply_invalid_status(&mut state, err.clone()); - Err(err) - } - } - } - None => { - apply_missing_status(&mut state); - if let LicenseStatus::Missing = state.status { - Err(LicenseError::Missing) - } else { - Ok(()) - } - } - } + license_provider().initialize(normalized.as_deref()) } /// Legacy name kept for existing startup code. @@ -290,7 +293,11 @@ pub fn init_license(license: Option) { /// Return the current license information. pub fn get_license() -> Option { - license_state().read().ok().and_then(|state| state.token.clone()) + let metadata = license_provider().metadata()?; + Some(Token { + name: metadata.subject, + expired: metadata.expires_at?, + }) } /// New name for compatibility with external integrations. @@ -300,55 +307,50 @@ pub fn current_license() -> Option { /// Return whether the loaded license token is present and not expired. pub fn has_valid_license() -> bool { - let Some(token) = get_license() else { + let provider = license_provider(); + if provider.status() != LicenseStatus::Valid || provider.check(SERVER_ENTITLEMENT).is_err() { return false; - }; - let Ok(now) = now_epoch_secs() else { - return false; - }; + } - is_license_token_current(&token, now) + match provider.metadata().and_then(|metadata| metadata.expires_at) { + Some(expires_at) => now_epoch_secs().is_ok_and(|now| expires_at > now), + None => true, + } } /// Observe the current license status for observability. pub fn license_status() -> String { - license_state() - .read() - .ok() - .map(|state| state.status.to_string()) - .unwrap_or_else(|| LicenseStatus::Uninitialized.to_string()) + license_provider().status().to_string() } -/// Check whether current in-memory license is currently valid. -#[cfg(feature = "license")] -pub fn ensure_license() -> LicenseResult<()> { - let state = license_state().read().map_err(|_| LicenseError::StatePoisoned)?; - match &state.status { - LicenseStatus::Missing => return Err(LicenseError::Missing), - LicenseStatus::Invalid(message) => return Err(LicenseError::Invalid(message.to_string())), - LicenseStatus::Uninitialized | LicenseStatus::Valid => {} - }; - - let token = state.token.as_ref().ok_or(LicenseError::Missing)?; - let now = now_epoch_secs()?; - if token.expired <= now { - return Err(LicenseError::Expired { - expired_at: token.expired, - now, - }); +/// Check a normalized entitlement identifier against the active provider. +pub fn check_entitlement(entitlement: &str) -> LicenseResult<()> { + if entitlement.is_empty() || entitlement.trim() != entitlement { + return Err(LicenseError::InvalidEntitlement); } - Ok(()) + license_provider().check(entitlement) } -#[cfg(not(feature = "license"))] +/// Check whether the existing server-wide license entitlement is granted. pub fn ensure_license() -> LicenseResult<()> { - Ok(()) + check_entitlement(SERVER_ENTITLEMENT) +} + +fn license_error_into_io(err: LicenseError) -> Error { + match err { + LicenseError::StatePoisoned | LicenseError::Clock(_) | LicenseError::Unavailable(_) => Error::other(err.to_string()), + LicenseError::Missing + | LicenseError::Invalid(_) + | LicenseError::Expired { .. } + | LicenseError::Denied { .. } + | LicenseError::InvalidEntitlement => Error::new(ErrorKind::PermissionDenied, err.to_string()), + } } /// Compatibility API for call-sites that still use the legacy name. pub fn license_check() -> Result<()> { - ensure_license().map_err(LicenseError::into_io) + ensure_license().map_err(license_error_into_io) } #[cfg(test)] @@ -373,6 +375,44 @@ mod tests { assert!(!is_license_token_current(&token, 101)); } + #[test] + fn entitlement_identifier_must_be_normalized() { + assert_eq!(check_entitlement(""), Err(LicenseError::InvalidEntitlement)); + assert_eq!(check_entitlement(" rustfs.server"), Err(LicenseError::InvalidEntitlement)); + assert_eq!(check_entitlement("rustfs.server "), Err(LicenseError::InvalidEntitlement)); + } + + #[test] + fn provider_errors_keep_legacy_io_error_kinds() { + let missing = license_error_into_io(LicenseError::Missing); + let unavailable = license_error_into_io(LicenseError::Unavailable("runtime failure".to_string())); + + assert_eq!(missing.kind(), ErrorKind::PermissionDenied); + assert_eq!(unavailable.kind(), ErrorKind::Other); + } + + #[test] + #[serial] + #[cfg(not(feature = "license"))] + fn open_source_provider_accepts_missing_license() { + let provider = OpenSourceLicenseProvider; + + assert_eq!(provider.initialize(None), Ok(())); + assert_eq!(provider.status(), LicenseStatus::Uninitialized); + assert_eq!(provider.check(SERVER_ENTITLEMENT), Ok(())); + } + + #[test] + #[serial] + #[cfg(feature = "license")] + fn strict_provider_rejects_missing_license() { + let provider = OpenSourceLicenseProvider; + + assert_eq!(provider.initialize(None), Err(LicenseError::Missing)); + assert_eq!(provider.status(), LicenseStatus::Missing); + assert_eq!(provider.check(SERVER_ENTITLEMENT), Err(LicenseError::Missing)); + } + #[test] #[serial] fn appauth_verifier_rejects_missing_public_key() {