diff --git a/crates/kms/src/backends/mod.rs b/crates/kms/src/backends/mod.rs index 88b1aabaa..72d4df7c5 100644 --- a/crates/kms/src/backends/mod.rs +++ b/crates/kms/src/backends/mod.rs @@ -438,7 +438,9 @@ mod tests { ) .with_insecure_development_defaults(); // Constructing the client performs no network I/O with token auth. - let backend = vault::VaultKmsBackend::new(config).await.expect("vault kv2 backend should build"); + let backend = vault::VaultKmsBackend::new(config) + .await + .expect("vault kv2 backend should build"); insta::assert_json_snapshot!("vault_kv2_backend_capabilities", capabilities_snapshot(backend.capabilities())); } diff --git a/crates/kms/src/manager.rs b/crates/kms/src/manager.rs index 3cfc8612c..c8bf9e115 100644 --- a/crates/kms/src/manager.rs +++ b/crates/kms/src/manager.rs @@ -159,6 +159,11 @@ impl KmsManager { pub async fn health_check(&self) -> Result { self.backend.health_check().await } + + /// Report the capabilities of the configured backend + pub fn backend_capabilities(&self) -> crate::backends::BackendCapabilities { + self.backend.capabilities() + } } #[cfg(test)] diff --git a/crates/kms/src/service.rs b/crates/kms/src/service.rs index 9b6874dfb..01902541b 100644 --- a/crates/kms/src/service.rs +++ b/crates/kms/src/service.rs @@ -184,6 +184,15 @@ impl ObjectEncryptionService { self.kms_manager.health_check().await } + /// Report the capabilities of the configured backend + /// + /// # Returns + /// The capability matrix advertised by the active KMS backend + /// + pub fn backend_capabilities(&self) -> crate::backends::BackendCapabilities { + self.kms_manager.backend_capabilities() + } + /// Create a data encryption key for object encryption /// /// # Arguments diff --git a/rustfs/src/admin/handlers/kms_management.rs b/rustfs/src/admin/handlers/kms_management.rs index bbeca1d90..88ee3a6f9 100644 --- a/rustfs/src/admin/handlers/kms_management.rs +++ b/rustfs/src/admin/handlers/kms_management.rs @@ -72,6 +72,10 @@ pub struct KmsStatusResponse { pub cache_enabled: bool, pub cache_stats: Option, pub default_key_id: Option, + /// Capability matrix of the active backend. Additive field: omitted by + /// older servers, so it must stay optional for consumers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -204,6 +208,7 @@ impl Operation for KmsStatusHandler { cache_enabled: config.as_ref().is_some_and(|cfg| cfg.enable_cache), cache_stats, default_key_id: service.get_default_key_id().cloned(), + capabilities: Some(service.backend_capabilities()), }; let data = serde_json::to_vec(&response).map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?; @@ -339,4 +344,34 @@ mod tests { fn kms_clear_cache_rejects_server_info_fallback() { assert_lacks_action(&kms_clear_cache_actions(), Action::AdminAction(AdminAction::ServerInfoAdminAction)); } + + /// The `capabilities` field is additive: payloads produced by older + /// servers (without the field) must keep deserializing, and the field + /// must be omitted from JSON when unset so existing consumers see an + /// unchanged response shape. + #[test] + fn kms_status_response_capabilities_field_is_additive() { + let legacy_json = serde_json::json!({ + "backend_type": "local", + "backend_status": "healthy", + "cache_enabled": true, + "cache_stats": null, + "default_key_id": null, + }); + let legacy: super::KmsStatusResponse = + serde_json::from_value(legacy_json).expect("legacy status payload should deserialize"); + assert!(legacy.capabilities.is_none()); + + let serialized = serde_json::to_value(&legacy).expect("status response should serialize"); + assert!(serialized.get("capabilities").is_none(), "unset capabilities must be omitted"); + + let with_capabilities = super::KmsStatusResponse { + capabilities: Some(rustfs_kms::backends::BackendCapabilities::minimal()), + ..legacy + }; + let serialized = serde_json::to_value(&with_capabilities).expect("status response should serialize"); + let capabilities = serialized.get("capabilities").expect("capabilities must be present when set"); + assert_eq!(capabilities.get("encrypt"), Some(&serde_json::Value::Bool(true))); + assert_eq!(capabilities.get("rotate"), Some(&serde_json::Value::Bool(false))); + } }