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)
This commit is contained in:
overtrue
2026-07-30 18:18:16 +08:00
parent 36b1723cec
commit 7b87bd2ee6
4 changed files with 52 additions and 1 deletions
+3 -1
View File
@@ -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()));
}
+5
View File
@@ -159,6 +159,11 @@ impl KmsManager {
pub async fn health_check(&self) -> Result<bool> {
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)]
+9
View File
@@ -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
@@ -72,6 +72,10 @@ pub struct KmsStatusResponse {
pub cache_enabled: bool,
pub cache_stats: Option<CacheStatsResponse>,
pub default_key_id: Option<String>,
/// 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<rustfs_kms::backends::BackendCapabilities>,
}
#[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)));
}
}