From 7b87bd2ee66b7c9ab281e3c38c1ffd59e45953e4 Mon Sep 17 00:00:00 2001 From: overtrue Date: Thu, 30 Jul 2026 18:18:16 +0800 Subject: [PATCH] feat(admin): expose KMS backend capabilities in status response Surface the backend capability matrix as an optional, additive 'capabilities' field on the /v3/kms/status response so management clients can discover supported lifecycle operations. The field is skipped when unset, keeping the response shape unchanged for existing consumers, and legacy payloads without it still deserialize. Refs rustfs/backlog#1571 (part of rustfs/backlog#1562) --- crates/kms/src/backends/mod.rs | 4 ++- crates/kms/src/manager.rs | 5 +++ crates/kms/src/service.rs | 9 ++++++ rustfs/src/admin/handlers/kms_management.rs | 35 +++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) 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))); + } }