mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-13 08:36:54 +00:00
feat(kms): add an AWS KMS backend (#5553)
This commit is contained in:
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user