diff --git a/crates/kms/src/api_types.rs b/crates/kms/src/api_types.rs index 221529f5c..953a521c2 100644 --- a/crates/kms/src/api_types.rs +++ b/crates/kms/src/api_types.rs @@ -235,15 +235,39 @@ pub struct StartKmsRequest { #[derive(Deserialize)] #[serde(deny_unknown_fields)] enum StrictVaultAuthMethod { - Token { token: String }, - AppRole { role_id: String, secret_id: String }, + Token { + token: String, + }, + AppRole { + role_id: String, + #[serde(default)] + secret_id: String, + #[serde(default)] + secret_id_file: Option, + #[serde(default)] + mount: Option, + #[serde(default)] + refresh_safety_window_secs: Option, + }, } impl From for VaultAuthMethod { fn from(value: StrictVaultAuthMethod) -> Self { match value { StrictVaultAuthMethod::Token { token } => Self::Token { token }, - StrictVaultAuthMethod::AppRole { role_id, secret_id } => Self::AppRole { role_id, secret_id }, + StrictVaultAuthMethod::AppRole { + role_id, + secret_id, + secret_id_file, + mount, + refresh_safety_window_secs, + } => Self::AppRole { + role_id, + secret_id, + secret_id_file, + mount: mount.unwrap_or_else(|| crate::config::DEFAULT_VAULT_APPROLE_MOUNT.to_string()), + refresh_safety_window_secs, + }, } } } @@ -872,10 +896,7 @@ mod tests { }); let approle = ConfigureKmsRequest::VaultKv2(ConfigureVaultKmsRequest { address: "https://vault.example.com:8200".to_string(), - auth_method: VaultAuthMethod::AppRole { - role_id: "configure-role-id".to_string(), - secret_id: "configure-approle-secret-id".to_string(), - }, + auth_method: VaultAuthMethod::approle("configure-role-id".to_string(), "configure-approle-secret-id".to_string()), namespace: None, mount_path: Some("transit".to_string()), kv_mount: Some("secret".to_string()), diff --git a/crates/kms/src/config.rs b/crates/kms/src/config.rs index 2e2f5e80e..8989df052 100644 --- a/crates/kms/src/config.rs +++ b/crates/kms/src/config.rs @@ -29,8 +29,13 @@ pub const ENV_KMS_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "RUSTFS_KMS_VAULT_TRAN pub const ENV_KMS_VAULT_TRANSIT_METADATA_PREFIX: &str = "RUSTFS_KMS_VAULT_TRANSIT_METADATA_PREFIX"; pub const ENV_KMS_STATIC_SECRET_KEY: &str = "RUSTFS_KMS_STATIC_SECRET_KEY"; pub const ENV_KMS_STATIC_SECRET_KEY_FILE: &str = "RUSTFS_KMS_STATIC_SECRET_KEY_FILE"; +pub const ENV_KMS_VAULT_APPROLE_ROLE_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_ROLE_ID"; +pub const ENV_KMS_VAULT_APPROLE_SECRET_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_SECRET_ID"; +pub const ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE: &str = "RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE"; +pub const ENV_KMS_VAULT_APPROLE_MOUNT: &str = "RUSTFS_KMS_VAULT_APPROLE_MOUNT"; pub const DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "secret"; pub const DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX: &str = "rustfs/kms/transit-metadata"; +pub const DEFAULT_VAULT_APPROLE_MOUNT: &str = "approle"; /// Upper bound applied to `KmsConfig::timeout` when deriving backend behavior. /// @@ -53,6 +58,10 @@ fn default_vault_kv2_mount_path() -> String { "transit".to_string() } +fn default_vault_approle_mount() -> String { + DEFAULT_VAULT_APPROLE_MOUNT.to_string() +} + pub const KMS_CONFIG_REDACTION_RULES: &[RedactionRule] = &[ RedactionRule::new("kms.local.master_key", RedactionLevel::Secret, "local backend key encryption material"), RedactionRule::new("kms.vault.token", RedactionLevel::Secret, "vault authentication token"), @@ -388,18 +397,59 @@ impl Default for VaultTransitConfig { pub enum VaultAuthMethod { /// Token authentication Token { token: String }, - /// AppRole authentication - AppRole { role_id: String, secret_id: String }, + /// AppRole authentication: login with `role_id` + `secret_id` for a + /// lease-bound token that is renewed in the background. + AppRole { + role_id: String, + /// Inline secret_id; used only when `secret_id_file` is unset. + secret_id: String, + /// Path to a file holding the secret_id. Re-read on every login so an + /// externally rotated secret_id is picked up; takes precedence over the + /// inline value. + #[serde(default)] + secret_id_file: Option, + /// AppRole auth engine mount path. + #[serde(default = "default_vault_approle_mount")] + mount: String, + /// Fail-closed margin in seconds: once the current token is within this + /// window of expiry without a successful refresh, requests are refused + /// instead of sent with a token that may lapse mid-flight. Defaults to + /// the per-attempt timeout. + #[serde(default)] + refresh_safety_window_secs: Option, + }, +} + +impl VaultAuthMethod { + /// AppRole authentication with the default mount and no secret-id file. + pub fn approle(role_id: String, secret_id: String) -> Self { + Self::AppRole { + role_id, + secret_id, + secret_id_file: None, + mount: default_vault_approle_mount(), + refresh_safety_window_secs: None, + } + } } impl fmt::Debug for VaultAuthMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Token { token } => f.debug_struct("Token").field("token", &redacted_secret(token)).finish(), - Self::AppRole { role_id, secret_id } => f + Self::AppRole { + role_id, + secret_id, + secret_id_file, + mount, + refresh_safety_window_secs, + } => f .debug_struct("AppRole") .field("role_id", role_id) .field("secret_id", &redacted_secret(secret_id)) + .field("secret_id_file", secret_id_file) + .field("mount", mount) + .field("refresh_safety_window_secs", refresh_safety_window_secs) .finish(), } } @@ -479,7 +529,7 @@ impl KmsConfig { backend: KmsBackend::VaultKv2, backend_config: BackendConfig::VaultKv2(Box::new(VaultConfig { address: address.to_string(), - auth_method: VaultAuthMethod::AppRole { role_id, secret_id }, + auth_method: VaultAuthMethod::approle(role_id, secret_id), ..Default::default() })), ..Default::default() @@ -637,6 +687,8 @@ impl KmsConfig { return Err(KmsError::configuration_error("Vault KV2 address must use http or https scheme")); } + validate_vault_auth_method("Vault KV2", &config.auth_method)?; + if !self.allow_insecure_dev_defaults { validate_vault_development_defaults("Vault KV2", &config.address, &config.auth_method, config.tls.as_ref())?; } @@ -660,6 +712,8 @@ impl KmsConfig { return Err(KmsError::configuration_error("Vault Transit address must use http or https scheme")); } + validate_vault_auth_method("Vault Transit", &config.auth_method)?; + if !self.allow_insecure_dev_defaults { validate_vault_development_defaults( "Vault Transit", @@ -764,7 +818,7 @@ impl KmsConfig { } KmsBackend::VaultKv2 => { let address = get_env_str("RUSTFS_KMS_VAULT_ADDRESS", "http://localhost:8200"); - let token = get_env_str("RUSTFS_KMS_VAULT_TOKEN", "dev-token"); + let auth_method = vault_auth_method_from_env()?; let skip_tls_verify = get_env_bool(ENV_KMS_VAULT_SKIP_TLS_VERIFY, false); let mount_path = match get_env_opt_str("RUSTFS_KMS_VAULT_MOUNT_PATH") { @@ -779,7 +833,7 @@ impl KmsConfig { config.backend_config = BackendConfig::VaultKv2(Box::new(VaultConfig { address, - auth_method: VaultAuthMethod::Token { token }, + auth_method, namespace: get_env_opt_str("RUSTFS_KMS_VAULT_NAMESPACE"), mount_path, kv_mount: get_env_str("RUSTFS_KMS_VAULT_KV_MOUNT", "secret"), @@ -789,12 +843,12 @@ impl KmsConfig { } KmsBackend::VaultTransit => { let address = get_env_str("RUSTFS_KMS_VAULT_ADDRESS", "http://localhost:8200"); - let token = get_env_str("RUSTFS_KMS_VAULT_TOKEN", "dev-token"); + let auth_method = vault_auth_method_from_env()?; let skip_tls_verify = get_env_bool(ENV_KMS_VAULT_SKIP_TLS_VERIFY, false); config.backend_config = BackendConfig::VaultTransit(Box::new(VaultTransitConfig { address, - auth_method: VaultAuthMethod::Token { token }, + auth_method, namespace: get_env_opt_str("RUSTFS_KMS_VAULT_NAMESPACE"), mount_path: get_env_str("RUSTFS_KMS_VAULT_MOUNT_PATH", "transit"), metadata_kv_mount: get_env_str( @@ -873,6 +927,63 @@ fn is_under_temp_dir(path: &Path) -> bool { path.starts_with(std::env::temp_dir()) } +/// Resolve the Vault auth method from environment variables. +/// +/// Setting `RUSTFS_KMS_VAULT_APPROLE_ROLE_ID` selects AppRole authentication; +/// the secret_id then comes from `RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE` +/// (re-read on every login, mirroring the `RUSTFS_KMS_STATIC_SECRET_KEY_FILE` +/// precedent) or inline from `RUSTFS_KMS_VAULT_APPROLE_SECRET_ID`, with the +/// file taking precedence. Without a role id the legacy token flow applies. +fn vault_auth_method_from_env() -> Result { + let Some(role_id) = get_env_opt_str(ENV_KMS_VAULT_APPROLE_ROLE_ID) else { + return Ok(VaultAuthMethod::Token { + token: get_env_str("RUSTFS_KMS_VAULT_TOKEN", "dev-token"), + }); + }; + + let secret_id_file = get_env_opt_str(ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE).map(PathBuf::from); + let secret_id = get_env_opt_str(ENV_KMS_VAULT_APPROLE_SECRET_ID).unwrap_or_default(); + if secret_id.is_empty() && secret_id_file.is_none() { + return Err(KmsError::configuration_error(format!( + "Vault AppRole requires {ENV_KMS_VAULT_APPROLE_SECRET_ID} or {ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE} to be set" + ))); + } + + Ok(VaultAuthMethod::AppRole { + role_id, + secret_id, + secret_id_file, + mount: get_env_str(ENV_KMS_VAULT_APPROLE_MOUNT, DEFAULT_VAULT_APPROLE_MOUNT), + refresh_safety_window_secs: None, + }) +} + +fn validate_vault_auth_method(backend_name: &str, auth_method: &VaultAuthMethod) -> Result<()> { + match auth_method { + VaultAuthMethod::Token { .. } => Ok(()), + VaultAuthMethod::AppRole { + role_id, + secret_id, + secret_id_file, + mount, + .. + } => { + if role_id.is_empty() { + return Err(KmsError::configuration_error(format!("{backend_name} AppRole role_id cannot be empty"))); + } + if secret_id.is_empty() && secret_id_file.is_none() { + return Err(KmsError::configuration_error(format!( + "{backend_name} AppRole requires a secret_id or a secret_id_file" + ))); + } + if mount.is_empty() { + return Err(KmsError::configuration_error(format!("{backend_name} AppRole mount cannot be empty"))); + } + Ok(()) + } + } +} + fn validate_vault_development_defaults( backend_name: &str, address: &str, @@ -1297,6 +1408,134 @@ mod tests { ); } + #[test] + fn test_from_env_selects_approle_when_role_id_is_set() { + with_vars( + vec![ + ("RUSTFS_KMS_BACKEND", Some("vault")), + ("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")), + (ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")), + (ENV_KMS_VAULT_APPROLE_SECRET_ID, Some("env-approle-secret-id")), + (ENV_KMS_VAULT_APPROLE_MOUNT, Some("approle-alt")), + // A stale token env var must not override the AppRole selection. + ("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")), + ], + || { + let config = KmsConfig::from_env().expect("kms config should load from env"); + let vault = config.vault_config().expect("vault backend config"); + let VaultAuthMethod::AppRole { + role_id, + secret_id, + secret_id_file, + mount, + refresh_safety_window_secs, + } = &vault.auth_method + else { + panic!("role id in the environment must select AppRole auth, got {:?}", vault.auth_method); + }; + assert_eq!(role_id, "env-role-id"); + assert_eq!(secret_id, "env-approle-secret-id"); + assert_eq!(secret_id_file, &None); + assert_eq!(mount, "approle-alt"); + assert_eq!(refresh_safety_window_secs, &None); + }, + ); + } + + #[test] + fn test_from_env_approle_secret_id_file_is_stored_as_path() { + with_vars( + vec![ + ("RUSTFS_KMS_BACKEND", Some("vault-transit")), + ("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")), + (ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")), + (ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE, Some("/etc/rustfs/approle-secret-id")), + ], + || { + let config = KmsConfig::from_env().expect("kms config should load from env"); + let vault = config.vault_transit_config().expect("vault transit backend config"); + let VaultAuthMethod::AppRole { + secret_id, + secret_id_file, + mount, + .. + } = &vault.auth_method + else { + panic!("role id in the environment must select AppRole auth"); + }; + // The path is stored, not read: the secret_id file is re-read on + // every login so external rotation is picked up. + assert_eq!(secret_id_file.as_deref(), Some(std::path::Path::new("/etc/rustfs/approle-secret-id"))); + assert!(secret_id.is_empty()); + assert_eq!(mount, DEFAULT_VAULT_APPROLE_MOUNT); + }, + ); + } + + #[test] + fn test_from_env_approle_requires_secret_id_or_file() { + with_vars( + vec![ + ("RUSTFS_KMS_BACKEND", Some("vault")), + (ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")), + (ENV_KMS_VAULT_APPROLE_SECRET_ID, None::<&str>), + (ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE, None::<&str>), + ], + || { + let error = KmsConfig::from_env().expect_err("approle without a secret_id source must be rejected"); + assert!(error.to_string().contains(ENV_KMS_VAULT_APPROLE_SECRET_ID)); + }, + ); + } + + #[test] + fn test_approle_config_deserializes_legacy_shape_with_defaults() { + // Persisted configurations from before the AppRole implementation only + // carry role_id and secret_id; the new fields must fill with defaults. + let legacy = serde_json::json!({ + "AppRole": { + "role_id": "legacy-role", + "secret_id": "legacy-secret-id", + } + }); + let auth: VaultAuthMethod = serde_json::from_value(legacy).expect("legacy AppRole config must keep deserializing"); + let VaultAuthMethod::AppRole { + role_id, + secret_id_file, + mount, + refresh_safety_window_secs, + .. + } = auth + else { + panic!("expected AppRole"); + }; + assert_eq!(role_id, "legacy-role"); + assert_eq!(secret_id_file, None); + assert_eq!(mount, DEFAULT_VAULT_APPROLE_MOUNT); + assert_eq!(refresh_safety_window_secs, None); + } + + #[test] + fn test_validate_rejects_incomplete_approle() { + let mut config = KmsConfig::vault_approle( + Url::parse("https://vault.example.com:8200").expect("vault URL"), + String::new(), + "secret-id".to_string(), + ); + let error = config.validate().expect_err("empty role_id must be rejected"); + assert!(error.to_string().contains("role_id")); + + config = KmsConfig::vault_approle( + Url::parse("https://vault.example.com:8200").expect("vault URL"), + "role-id".to_string(), + String::new(), + ); + let error = config + .validate() + .expect_err("approle without secret_id or secret_id_file must be rejected"); + assert!(error.to_string().contains("secret_id")); + } + #[test] fn test_from_env_requires_vault_development_opt_in() { with_vars( diff --git a/crates/kms/src/error.rs b/crates/kms/src/error.rs index 98fbb5008..35bc6853e 100644 --- a/crates/kms/src/error.rs +++ b/crates/kms/src/error.rs @@ -128,6 +128,11 @@ pub enum KmsError { /// Backup/restore bundle contract violation; see [`crate::backup::BackupError`] #[error(transparent)] Backup(#[from] crate::backup::BackupError), + + /// Backend credentials expired or could not be refreshed in time; requests + /// fail closed instead of being sent with credentials that may lapse mid-flight + #[error("KMS credentials unavailable: {message}")] + CredentialsUnavailable { message: String }, } impl KmsError { @@ -269,6 +274,11 @@ impl KmsError { version, } } + + /// Create a credentials unavailable error + pub fn credentials_unavailable>(message: S) -> Self { + Self::CredentialsUnavailable { message: message.into() } + } } /// Convert from standard library errors