feat(kms): add an AWS KMS backend (#5553)

This commit is contained in:
Zhengchao An
2026-08-01 14:07:32 +08:00
committed by GitHub
parent 35e4415ed9
commit 322ce21b9a
16 changed files with 1574 additions and 10 deletions
+13
View File
@@ -77,6 +77,16 @@ vaultrs = { workspace = true }
rustify = { workspace = true }
tokio-util = { workspace = true }
# AWS KMS backend. Credentials come from the standard aws-config provider chain
# (environment, shared profile, IMDS/container roles); this crate never handles
# AWS credential material itself.
aws-config = { workspace = true }
aws-sdk-kms = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
# SdkError variants and raw HTTP status are needed to classify AWS failures for
# the operation policy's retry decisions.
aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
aws-smithy-types = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
# Debugging recorder for asserting emitted metrics in tests.
@@ -86,6 +96,9 @@ tempfile = { workspace = true }
temp-env = { workspace = true }
# "net" backs the scripted loopback Vault used by the policy wiring tests.
tokio = { workspace = true, features = ["net", "test-util"] }
# Replays canned AWS KMS HTTP exchanges so the AWS backend tests stay offline.
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
http = { workspace = true }
[features]
default = []
+11
View File
@@ -418,6 +418,13 @@ pub enum BackendSummary {
/// Configured key identifier
key_id: String,
},
/// AWS KMS backend summary
Aws {
/// Configured region, when pinned instead of resolved by the AWS chain
region: Option<String>,
/// Endpoint override, when set for an emulator or private endpoint
endpoint_url: Option<String>,
},
}
impl From<&KmsConfig> for KmsConfigSummary {
@@ -467,6 +474,10 @@ impl From<&KmsConfig> for KmsConfigSummary {
BackendConfig::Static(static_config) => BackendSummary::Static {
key_id: static_config.key_id.clone(),
},
BackendConfig::Aws(aws_config) => BackendSummary::Aws {
region: aws_config.region.clone(),
endpoint_url: aws_config.endpoint_url.clone(),
},
};
Self {
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1386,7 +1386,8 @@ impl LocalKmsBackend {
crate::config::BackendConfig::Local(local_config) => local_config.clone(),
crate::config::BackendConfig::VaultKv2(_)
| crate::config::BackendConfig::VaultTransit(_)
| crate::config::BackendConfig::Static(_) => {
| crate::config::BackendConfig::Static(_)
| crate::config::BackendConfig::Aws(_) => {
return Err(KmsError::configuration_error("Expected Local backend configuration"));
}
};
+1
View File
@@ -20,6 +20,7 @@ use async_trait::async_trait;
use jiff::Zoned;
use serde::{Deserialize, Serialize};
pub mod aws;
#[cfg(test)]
mod contract_tests;
pub mod local;
@@ -0,0 +1,14 @@
---
source: crates/kms/src/backends/aws.rs
expression: capabilities_snapshot(backend.capabilities())
---
{
"decrypt": true,
"enable_disable": true,
"encrypt": true,
"generate_data_key": true,
"physical_delete": false,
"rotate": true,
"schedule_deletion": true,
"versioning": true
}
+2 -1
View File
@@ -1163,7 +1163,8 @@ impl VaultKmsBackend {
crate::config::BackendConfig::VaultKv2(vault_config) => (**vault_config).clone(),
crate::config::BackendConfig::Local(_)
| crate::config::BackendConfig::VaultTransit(_)
| crate::config::BackendConfig::Static(_) => {
| crate::config::BackendConfig::Static(_)
| crate::config::BackendConfig::Aws(_) => {
return Err(KmsError::configuration_error("Expected Vault KV2 backend configuration"));
}
};
+3 -1
View File
@@ -1012,7 +1012,9 @@ impl VaultTransitKmsBackend {
metadata_key_prefix: vault_config.key_path_prefix.clone(),
tls: vault_config.tls.clone(),
},
crate::config::BackendConfig::Local(_) | crate::config::BackendConfig::Static(_) => {
crate::config::BackendConfig::Local(_)
| crate::config::BackendConfig::Static(_)
| crate::config::BackendConfig::Aws(_) => {
return Err(KmsError::configuration_error("Expected Vault Transit backend configuration"));
}
};
+118
View File
@@ -34,6 +34,8 @@ pub const ENV_KMS_VAULT_APPROLE_SECRET_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_SECR
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 ENV_KMS_VAULT_TOKEN_FILE: &str = "RUSTFS_KMS_VAULT_TOKEN_FILE";
pub const ENV_KMS_AWS_REGION: &str = "RUSTFS_KMS_AWS_REGION";
pub const ENV_KMS_AWS_ENDPOINT_URL: &str = "RUSTFS_KMS_AWS_ENDPOINT_URL";
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";
@@ -128,6 +130,10 @@ pub enum KmsBackend {
/// Static single-key backend that derives DEKs from a pre-configured key
#[serde(rename = "Static")]
Static,
/// AWS KMS backend: AWS is the cryptographic source of truth and owns key
/// state, versioning, and the deletion window.
#[serde(rename = "AWS", alias = "AwsKms")]
Aws,
}
impl KmsBackend {
@@ -141,6 +147,7 @@ impl KmsBackend {
KmsBackend::VaultTransit => "vault-transit",
KmsBackend::Local => "local",
KmsBackend::Static => "static",
KmsBackend::Aws => "aws",
}
}
}
@@ -200,6 +207,9 @@ pub enum BackendConfig {
VaultTransit(Box<VaultTransitConfig>),
/// Static single-key backend configuration
Static(StaticConfig),
/// AWS KMS backend configuration
#[serde(rename = "AWS", alias = "AwsKms")]
Aws(Box<AwsKmsConfig>),
}
impl Default for BackendConfig {
@@ -215,6 +225,7 @@ impl fmt::Debug for BackendConfig {
Self::VaultKv2(config) => f.debug_tuple("VaultKv2").field(config).finish(),
Self::VaultTransit(config) => f.debug_tuple("VaultTransit").field(config).finish(),
Self::Static(config) => f.debug_tuple("Static").field(config).finish(),
Self::Aws(config) => f.debug_tuple("Aws").field(config).finish(),
}
}
}
@@ -540,6 +551,25 @@ impl Default for CacheConfig {
}
}
/// AWS KMS backend configuration.
///
/// Deliberately holds no credential material: the backend resolves credentials
/// through the standard `aws-config` provider chain (environment, shared
/// profile, container/IMDS role), so RustFS never stores, persists, or redacts
/// AWS secrets of its own.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AwsKmsConfig {
/// AWS region hosting the KMS keys. When unset, the region is resolved by
/// the standard chain (`AWS_REGION`, profile, IMDS).
#[serde(default)]
pub region: Option<String>,
/// Override for the KMS endpoint, for local emulators and private
/// endpoints. Unset in production, where the SDK derives the regional
/// endpoint.
#[serde(default)]
pub endpoint_url: Option<String>,
}
impl KmsConfig {
/// Create a new KMS configuration for local backend (for development and testing only)
pub fn local(key_dir: PathBuf) -> Self {
@@ -649,6 +679,29 @@ impl KmsConfig {
}
}
/// Create a new KMS configuration for the AWS KMS backend.
///
/// Credentials are resolved by the standard `aws-config` provider chain;
/// only the region is configured here.
pub fn aws(region: Option<String>) -> Self {
Self {
backend: KmsBackend::Aws,
backend_config: BackendConfig::Aws(Box::new(AwsKmsConfig {
region,
endpoint_url: None,
})),
..Default::default()
}
}
/// Get the AWS configuration if backend is AWS KMS
pub fn aws_kms_config(&self) -> Option<&AwsKmsConfig> {
match &self.backend_config {
BackendConfig::Aws(config) => Some(config),
_ => None,
}
}
/// Set default key ID
pub fn with_default_key(mut self, key_id: String) -> Self {
self.default_key_id = Some(key_id);
@@ -805,6 +858,25 @@ impl KmsConfig {
// Validate that the key can be decoded (right length, valid base64)
config.decode_key()?;
}
BackendConfig::Aws(config) => {
if let Some(region) = &config.region
&& region.is_empty()
{
return Err(KmsError::configuration_error("AWS KMS region cannot be empty when set"));
}
if let Some(endpoint) = &config.endpoint_url {
if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
return Err(KmsError::configuration_error("AWS KMS endpoint URL must use http or https scheme"));
}
// A plaintext endpoint override exposes every KMS request,
// including plaintext data keys, so it stays gated on the
// explicit development opt-in.
if endpoint.starts_with("http://") && !self.allow_insecure_dev_defaults {
return Err(development_default_error("AWS KMS endpoint URL must use https"));
}
}
}
}
// Validate cache configuration
@@ -826,6 +898,7 @@ impl KmsConfig {
"vault" | "vault-kv2" | "vault_kv2" => KmsBackend::VaultKv2,
"vault-transit" | "vault_transit" => KmsBackend::VaultTransit,
"static" => KmsBackend::Static,
"aws" | "aws-kms" | "aws_kms" => KmsBackend::Aws,
_ => return Err(KmsError::configuration_error(format!("Unknown KMS backend: {backend_type}"))),
};
}
@@ -954,6 +1027,14 @@ impl KmsConfig {
});
config.default_key_id = Some(key_id);
}
KmsBackend::Aws => {
// Only non-credential settings are read here; access keys,
// profiles, and role assumption stay with the aws-config chain.
config.backend_config = BackendConfig::Aws(Box::new(AwsKmsConfig {
region: get_env_opt_str(ENV_KMS_AWS_REGION),
endpoint_url: get_env_opt_str(ENV_KMS_AWS_ENDPOINT_URL),
}));
}
}
config.validate()?;
@@ -1555,6 +1636,43 @@ mod tests {
);
}
/// The AWS backend reads only non-credential settings from the
/// environment; access keys, profiles, and role assumption stay with the
/// aws-config provider chain.
#[test]
fn test_from_env_selects_aws_backend_without_credentials() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("aws")),
(ENV_KMS_AWS_REGION, Some("eu-central-1")),
(ENV_KMS_AWS_ENDPOINT_URL, None::<&str>),
],
|| {
let config = KmsConfig::from_env().expect("kms config should load from env");
assert_eq!(config.backend, KmsBackend::Aws);
let aws = config.aws_kms_config().expect("aws backend config");
assert_eq!(aws.region.as_deref(), Some("eu-central-1"));
assert_eq!(aws.endpoint_url, None);
},
);
}
/// A plaintext endpoint override exposes every KMS request, including the
/// plaintext data keys, so it stays behind the explicit development opt-in.
#[test]
fn test_from_env_rejects_plaintext_aws_endpoint() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("aws")),
(ENV_KMS_AWS_REGION, Some("us-east-1")),
(ENV_KMS_AWS_ENDPOINT_URL, Some("http://localhost:4566")),
],
|| {
KmsConfig::from_env().expect_err("a plaintext AWS endpoint must be rejected by default");
},
);
}
#[test]
fn test_from_env_approle_requires_secret_id_or_file() {
with_vars(
+3 -1
View File
@@ -104,7 +104,9 @@ pub(crate) fn classify_vaultrs(error: &vaultrs::error::ClientError) -> ErrorClas
}
}
fn classify_status(code: u16) -> ErrorClass {
/// Retry classification of an HTTP status, shared by every backend that talks
/// to an external KMS over HTTP.
pub(crate) fn classify_status(code: u16) -> ErrorClass {
match code {
429 | 500 | 502 | 503 | 504 => ErrorClass::RetryableStatus,
_ => ErrorClass::Fatal,
+5
View File
@@ -626,6 +626,11 @@ impl KmsServiceManager {
let backend = crate::backends::static_kms::StaticKmsBackend::new(config.clone()).await?;
Arc::new(backend) as Arc<dyn KmsBackend>
}
BackendConfig::Aws(_) => {
info!("Creating AWS KMS backend for version {}", version);
let backend = crate::backends::aws::AwsKmsBackend::new(config.clone()).await?;
Arc::new(backend) as Arc<dyn KmsBackend>
}
};
// Create KMS manager