mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 16:37:07 +00:00
456 lines
20 KiB
Rust
456 lines
20 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Black-box behavior: KMS service lifecycle, configuration, and redaction.
|
|
//!
|
|
//! Pins the contract the admin API depends on:
|
|
//!
|
|
//! * an unconfigured manager hands out nothing and refuses to start;
|
|
//! * `configure` is for the stopped state only — changing a running service is
|
|
//! `reconfigure`, which never exposes a stopped interval;
|
|
//! * a configuration is validated *before* anything is published, and a failing
|
|
//! persistence callback leaves the previous state completely intact;
|
|
//! * the Local backend's identity (directory, master key, dev-mode) is frozen
|
|
//! once configured, because changing it silently orphans existing key files;
|
|
//! * redacted views never carry key material.
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use common::{STATIC_KEY_ID, TestKms, assert_configuration_error, assert_no_secret_leak, static_secret_key};
|
|
use rustfs_kms::{BackendConfig, KmsConfig, KmsError, KmsServiceManager, KmsServiceStatus, KmsStartOutcome, LocalConfig};
|
|
use tempfile::TempDir;
|
|
use url::Url;
|
|
|
|
fn local_dev_config(dir: &TempDir) -> KmsConfig {
|
|
KmsConfig::local(dir.path().to_path_buf()).with_insecure_development_defaults()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unconfigured_manager_exposes_nothing_and_refuses_to_start() {
|
|
let manager = KmsServiceManager::new();
|
|
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::NotConfigured);
|
|
assert!(manager.get_config().await.is_none(), "no config before configure");
|
|
assert!(manager.get_redacted_config().await.is_none(), "no redacted config either");
|
|
assert!(manager.get_manager().await.is_none(), "no manager handle before start");
|
|
assert!(manager.get_encryption_service().await.is_none(), "no service handle before start");
|
|
assert!(manager.get_service_version().await.is_none(), "no version before start");
|
|
|
|
// A health check on a service that was never started is a definite "not
|
|
// healthy", not an error: callers use it as a readiness probe.
|
|
assert!(
|
|
!manager.health_check().await.expect("health check must not error when idle"),
|
|
"an unstarted service must report unhealthy"
|
|
);
|
|
|
|
assert_configuration_error(manager.start().await, "no configuration provided");
|
|
|
|
// The failed start records the reason and still exposes no service.
|
|
match manager.get_status().await {
|
|
KmsServiceStatus::Error(message) => {
|
|
assert!(message.contains("no configuration"), "error status should explain why: {message}")
|
|
}
|
|
other => panic!("expected Error status after starting without config, got {other:?}"),
|
|
}
|
|
assert!(manager.get_encryption_service().await.is_none());
|
|
assert!(manager.get_service_version().await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configure_start_stop_restart_state_machine() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let manager = KmsServiceManager::new();
|
|
let config = local_dev_config(&dir);
|
|
|
|
manager.configure(config.clone()).await.expect("configure should succeed");
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Configured);
|
|
assert!(
|
|
manager.get_encryption_service().await.is_none(),
|
|
"configured but not started must not expose a service"
|
|
);
|
|
assert!(manager.get_config().await.is_some(), "configure publishes the config");
|
|
|
|
manager.start().await.expect("start should succeed");
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
|
assert_eq!(manager.get_service_version().await, Some(1), "first start is version 1");
|
|
assert!(manager.get_manager().await.is_some());
|
|
assert!(manager.get_encryption_service().await.is_some());
|
|
assert!(manager.health_check().await.expect("health check"), "a started local KMS is healthy");
|
|
|
|
manager.stop().await.expect("stop should succeed");
|
|
assert_eq!(
|
|
manager.get_status().await,
|
|
KmsServiceStatus::Configured,
|
|
"stop falls back to Configured, not NotConfigured, because the config survives"
|
|
);
|
|
assert!(manager.get_encryption_service().await.is_none(), "stop withdraws the service");
|
|
assert!(manager.get_service_version().await.is_none(), "stop clears the published version");
|
|
assert!(manager.get_config().await.is_some(), "stop keeps the configuration");
|
|
assert!(!manager.health_check().await.expect("health check"), "a stopped service is unhealthy");
|
|
|
|
// Restart after stop reuses the surviving config and takes the next version.
|
|
manager.start().await.expect("restart after stop should succeed");
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
|
assert_eq!(
|
|
manager.get_service_version().await,
|
|
Some(2),
|
|
"the version counter is monotonic across a stop/start cycle"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_or_restart_reports_the_action_it_took() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let manager = KmsServiceManager::new();
|
|
manager
|
|
.configure(local_dev_config(&dir))
|
|
.await
|
|
.expect("configure should succeed");
|
|
|
|
assert_eq!(
|
|
manager.start_or_restart(false).await.expect("first start"),
|
|
KmsStartOutcome::Started,
|
|
"starting a stopped service reports Started"
|
|
);
|
|
assert_eq!(manager.get_service_version().await, Some(1));
|
|
|
|
let service_v1 = manager.get_encryption_service().await.expect("service after start");
|
|
|
|
assert_eq!(
|
|
manager.start_or_restart(false).await.expect("non-forced start while running"),
|
|
KmsStartOutcome::AlreadyRunning,
|
|
"a non-forced start on a running service is a no-op"
|
|
);
|
|
assert_eq!(manager.get_service_version().await, Some(1), "the no-op path must not consume a version");
|
|
assert!(
|
|
Arc::ptr_eq(&service_v1, &manager.get_encryption_service().await.expect("service still running")),
|
|
"a no-op start must not swap the live service instance"
|
|
);
|
|
|
|
assert_eq!(
|
|
manager.start_or_restart(true).await.expect("forced restart"),
|
|
KmsStartOutcome::Restarted,
|
|
"a forced start on a running service reports Restarted"
|
|
);
|
|
assert_eq!(manager.get_service_version().await, Some(2), "a forced restart takes a new version");
|
|
assert!(
|
|
!Arc::ptr_eq(&service_v1, &manager.get_encryption_service().await.expect("service after restart")),
|
|
"a forced restart must publish a different instance"
|
|
);
|
|
|
|
// The withdrawn instance keeps working for operations that already hold it.
|
|
assert!(
|
|
service_v1.health_check().await.expect("old handle health check"),
|
|
"a replaced service stays usable while callers still hold it"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configure_is_rejected_while_running() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let manager = KmsServiceManager::new();
|
|
let config = local_dev_config(&dir);
|
|
manager.configure(config.clone()).await.expect("configure");
|
|
manager.start().await.expect("start");
|
|
|
|
assert_configuration_error(manager.configure(config.clone()).await, "use reconfigure instead");
|
|
|
|
// The rejected call must not have disturbed the running service.
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
|
assert_eq!(manager.get_service_version().await, Some(1), "a rejected configure takes no version");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reconfigure_swaps_the_service_without_a_stopped_interval() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let manager = KmsServiceManager::new();
|
|
let config = local_dev_config(&dir);
|
|
manager.configure(config.clone()).await.expect("configure");
|
|
manager.start().await.expect("start");
|
|
|
|
let service_v1 = manager.get_encryption_service().await.expect("service v1");
|
|
let manager_v1 = manager.get_manager().await.expect("manager v1");
|
|
|
|
let mut next = config.clone();
|
|
next.timeout = Duration::from_secs(45);
|
|
manager.reconfigure(next).await.expect("reconfigure should succeed");
|
|
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running, "reconfigure never reports stopped");
|
|
assert_eq!(manager.get_service_version().await, Some(2));
|
|
assert_eq!(
|
|
manager.get_config().await.expect("config after reconfigure").timeout,
|
|
Duration::from_secs(45),
|
|
"the published config must be the new one"
|
|
);
|
|
|
|
let service_v2 = manager.get_encryption_service().await.expect("service v2");
|
|
let manager_v2 = manager.get_manager().await.expect("manager v2");
|
|
assert!(!Arc::ptr_eq(&service_v1, &service_v2), "reconfigure publishes a new service");
|
|
assert!(!Arc::ptr_eq(&manager_v1, &manager_v2), "reconfigure publishes a new manager");
|
|
|
|
// Both generations stay functional: that is the whole point of the swap.
|
|
assert!(service_v1.health_check().await.expect("v1 health"), "old generation still serves");
|
|
assert!(service_v2.health_check().await.expect("v2 health"), "new generation serves");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_persistence_does_not_publish_the_new_configuration() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let manager = KmsServiceManager::new();
|
|
|
|
// configure_with_persistence: nothing is published when persistence fails.
|
|
let result = manager
|
|
.configure_with_persistence(local_dev_config(&dir), || async {
|
|
Err(KmsError::backend_error("simulated persistence failure"))
|
|
})
|
|
.await;
|
|
assert!(
|
|
matches!(result, Err(KmsError::BackendError { .. })),
|
|
"the persistence error must propagate verbatim, got {result:?}"
|
|
);
|
|
assert_eq!(
|
|
manager.get_status().await,
|
|
KmsServiceStatus::NotConfigured,
|
|
"a failed persist must leave the manager unconfigured"
|
|
);
|
|
assert!(manager.get_config().await.is_none(), "a failed persist must publish no config");
|
|
|
|
// Now bring it up for real, then fail persistence on a reconfigure.
|
|
let config = local_dev_config(&dir);
|
|
manager.configure(config.clone()).await.expect("configure");
|
|
manager.start().await.expect("start");
|
|
let service_v1 = manager.get_encryption_service().await.expect("service v1");
|
|
|
|
let mut next = config.clone();
|
|
next.timeout = Duration::from_secs(99);
|
|
let result = manager
|
|
.reconfigure_with_persistence(next, || async { Err(KmsError::backend_error("simulated persistence failure")) })
|
|
.await;
|
|
assert!(result.is_err(), "reconfigure must fail when persistence fails, got {result:?}");
|
|
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running, "the old service keeps running");
|
|
assert_eq!(
|
|
manager.get_config().await.expect("config").timeout,
|
|
config.timeout,
|
|
"the old configuration must still be the published one"
|
|
);
|
|
assert!(
|
|
Arc::ptr_eq(&service_v1, &manager.get_encryption_service().await.expect("service")),
|
|
"a failed reconfigure must not swap the live service"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_backend_identity_is_frozen_after_configuration() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let other_dir = TempDir::new().expect("other temp dir");
|
|
let manager = KmsServiceManager::new();
|
|
let config = local_dev_config(&dir);
|
|
manager.configure(config.clone()).await.expect("configure");
|
|
manager.start().await.expect("start");
|
|
|
|
// Moving the key directory would orphan every existing key file.
|
|
let mut moved = config.clone();
|
|
moved.backend_config = BackendConfig::Local(LocalConfig {
|
|
key_dir: other_dir.path().to_path_buf(),
|
|
master_key: None,
|
|
file_permissions: Some(0o600),
|
|
});
|
|
assert_configuration_error(manager.reconfigure(moved).await, "key directory cannot be changed");
|
|
|
|
// Changing the at-rest master key would make stored material undecryptable.
|
|
let mut rekeyed = config.clone();
|
|
rekeyed.backend_config = BackendConfig::Local(LocalConfig {
|
|
key_dir: dir.path().to_path_buf(),
|
|
master_key: Some("a-different-master-key".to_string()),
|
|
file_permissions: Some(0o600),
|
|
});
|
|
assert_configuration_error(manager.reconfigure(rekeyed).await, "master key cannot be changed");
|
|
|
|
// Flipping dev mode changes the at-rest protection of the same directory.
|
|
let mut hardened = config.clone();
|
|
hardened.allow_insecure_dev_defaults = false;
|
|
hardened.backend_config = BackendConfig::Local(LocalConfig {
|
|
key_dir: dir.path().to_path_buf(),
|
|
master_key: Some("a-master-key".to_string()),
|
|
file_permissions: Some(0o600),
|
|
});
|
|
assert!(
|
|
manager.reconfigure(hardened).await.is_err(),
|
|
"flipping development mode on a configured local backend must be refused"
|
|
);
|
|
|
|
// Switching away from Local entirely is refused for the same reason.
|
|
let switched = KmsConfig::static_kms(STATIC_KEY_ID.to_string(), static_secret_key());
|
|
assert_configuration_error(manager.reconfigure(switched).await, "backend cannot be changed");
|
|
|
|
// Every rejection above left the original service untouched.
|
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
|
assert_eq!(
|
|
manager.get_service_version().await,
|
|
Some(1),
|
|
"rejected transitions must not consume service versions"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_configurations_are_rejected_before_anything_starts() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
|
|
// A zero timeout or zero retry budget would make every operation fail.
|
|
let mut zero_timeout = local_dev_config(&dir);
|
|
zero_timeout.timeout = Duration::ZERO;
|
|
assert_configuration_error(zero_timeout.validate(), "Timeout must be greater than 0");
|
|
|
|
let mut zero_retries = local_dev_config(&dir);
|
|
zero_retries.retry_attempts = 0;
|
|
assert_configuration_error(zero_retries.validate(), "Retry attempts must be greater than 0");
|
|
|
|
// Cache enabled with no capacity is contradictory.
|
|
let mut empty_cache = local_dev_config(&dir);
|
|
empty_cache.enable_cache = true;
|
|
empty_cache.cache_config.max_keys = 0;
|
|
assert_configuration_error(empty_cache.validate(), "max_keys must be greater than 0");
|
|
|
|
// A relative key directory is ambiguous relative to the server's cwd.
|
|
let mut relative = local_dev_config(&dir);
|
|
relative.backend_config = BackendConfig::Local(LocalConfig {
|
|
key_dir: "relative/kms".into(),
|
|
master_key: None,
|
|
file_permissions: Some(0o600),
|
|
});
|
|
assert_configuration_error(relative.validate(), "must be an absolute path");
|
|
|
|
// Production-shaped Local config: no master key is refused without opt-in.
|
|
let production_without_master_key = KmsConfig::local("/var/lib/rustfs/kms-behavior".into());
|
|
assert_configuration_error(production_without_master_key.validate(), "requires a master key");
|
|
|
|
// ...and so is a key directory under the process temp dir.
|
|
let temp_backed = KmsConfig {
|
|
backend_config: BackendConfig::Local(LocalConfig {
|
|
key_dir: dir.path().to_path_buf(),
|
|
master_key: Some("a-master-key".to_string()),
|
|
file_permissions: Some(0o600),
|
|
}),
|
|
..KmsConfig::local(dir.path().to_path_buf())
|
|
};
|
|
assert_configuration_error(temp_backed.validate(), "temp directory");
|
|
|
|
// Static backend: the secret must be base64 and exactly 32 bytes.
|
|
assert_configuration_error(
|
|
KmsConfig::static_kms("k".to_string(), "not base64!!".to_string()).validate(),
|
|
"not valid base64",
|
|
);
|
|
assert_configuration_error(
|
|
KmsConfig::static_kms("k".to_string(), base64_of(&[0u8; 16])).validate(),
|
|
"exactly 32 bytes",
|
|
);
|
|
assert_configuration_error(
|
|
KmsConfig::static_kms(String::new(), static_secret_key()).validate(),
|
|
"key_id cannot be empty",
|
|
);
|
|
assert_configuration_error(
|
|
KmsConfig::static_kms("k".to_string(), String::new()).validate(),
|
|
"secret_key cannot be empty",
|
|
);
|
|
|
|
// Vault: plaintext HTTP and the built-in dev token are dev-only.
|
|
let http = Url::parse("http://127.0.0.1:8200").expect("url");
|
|
assert_configuration_error(KmsConfig::vault(http.clone(), "a-real-token".to_string()).validate(), "requires HTTPS");
|
|
let https = Url::parse("https://vault.example.com:8200").expect("url");
|
|
assert_configuration_error(
|
|
KmsConfig::vault(https.clone(), "dev-token".to_string()).validate(),
|
|
"dev-token is not allowed",
|
|
);
|
|
// An AppRole with no credential at all cannot authenticate.
|
|
let approle = KmsConfig::vault_approle(https, "role".to_string(), String::new());
|
|
assert_configuration_error(approle.validate(), "requires a secret_id");
|
|
|
|
// None of the above may be startable through the manager either.
|
|
let manager = KmsServiceManager::new();
|
|
assert_configuration_error(manager.configure(zero_timeout).await, "Timeout must be greater than 0");
|
|
assert_eq!(
|
|
manager.get_status().await,
|
|
KmsServiceStatus::NotConfigured,
|
|
"a rejected configure must not move the manager out of NotConfigured"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redacted_views_never_carry_key_material() {
|
|
let secret = static_secret_key();
|
|
let manager = KmsServiceManager::new();
|
|
manager
|
|
.configure(KmsConfig::static_kms(STATIC_KEY_ID.to_string(), secret.clone()))
|
|
.await
|
|
.expect("configure static");
|
|
manager.start().await.expect("start static");
|
|
|
|
let redacted = manager.get_redacted_config().await.expect("redacted config");
|
|
let static_config = redacted.static_config().expect("static config");
|
|
assert!(static_config.secret_key.is_empty(), "the redacted view must zero the secret key");
|
|
assert_eq!(static_config.key_id, STATIC_KEY_ID, "the key id is not secret and must survive");
|
|
|
|
let (status, from_state) = manager.get_redacted_state().await;
|
|
assert_eq!(status, KmsServiceStatus::Running, "redacted state carries the live status");
|
|
let from_state = from_state.expect("redacted state config");
|
|
assert!(
|
|
from_state.static_config().expect("static config").secret_key.is_empty(),
|
|
"get_redacted_state must redact exactly like get_redacted_config"
|
|
);
|
|
|
|
// Neither the Debug rendering nor a JSON serialization may carry material.
|
|
assert_no_secret_leak(&format!("{redacted:?}"), &[&secret]);
|
|
assert_no_secret_leak(&serde_json::to_string(&redacted).expect("redacted config serializes"), &[&secret]);
|
|
|
|
// Even the unredacted config must keep the secret out of Debug and JSON:
|
|
// `Debug` is what ends up in logs, and `secret_key` is `skip_serializing`.
|
|
let live = manager.get_config().await.expect("live config");
|
|
assert_no_secret_leak(&format!("{live:?}"), &[&secret]);
|
|
assert_no_secret_leak(&serde_json::to_string(&live).expect("live config serializes"), &[&secret]);
|
|
|
|
// A Vault token is subject to the same rule in Debug output.
|
|
let vault = KmsConfig::vault(
|
|
Url::parse("https://vault.example.com:8200").expect("url"),
|
|
"super-secret-vault-token".to_string(),
|
|
);
|
|
assert_no_secret_leak(&format!("{vault:?}"), &["super-secret-vault-token"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn harness_restart_brings_the_service_back_over_the_same_state() {
|
|
let mut kms = TestKms::local().await;
|
|
assert_eq!(kms.manager().get_service_version().await, Some(1));
|
|
|
|
kms.restart().await;
|
|
|
|
assert_eq!(kms.manager().get_status().await, KmsServiceStatus::Running);
|
|
assert_eq!(
|
|
kms.manager().get_service_version().await,
|
|
Some(1),
|
|
"a restarted process starts its version counter over"
|
|
);
|
|
assert!(
|
|
kms.manager().health_check().await.expect("health check"),
|
|
"the restarted service is healthy"
|
|
);
|
|
}
|
|
|
|
fn base64_of(bytes: &[u8]) -> String {
|
|
base64_simd::STANDARD.encode_to_string(bytes)
|
|
}
|