mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 16:28:15 +00:00
fix(admin): redact and seal config history (#5206)
This commit is contained in:
@@ -16,7 +16,8 @@ use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::handlers::supervise_admin_mutation;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{
|
||||
current_app_context, current_object_store_handle_for_context, current_server_config_for_context, publish_server_config,
|
||||
current_action_credentials, current_app_context, current_object_store_handle_for_context, current_server_config_for_context,
|
||||
publish_server_config,
|
||||
};
|
||||
use crate::admin::service::config::{
|
||||
CONFIG_WORKER_RELOAD_FAILURE_STATE, EVENT_CONFIG_WORKER_RELOAD_FAILED, FULL_CONFIG_WORKER_SUBSYSTEMS, LOG_COMPONENT_ADMIN,
|
||||
@@ -86,6 +87,7 @@ use serde::Serialize;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::env;
|
||||
use std::future::Future;
|
||||
use std::mem::size_of;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
@@ -97,6 +99,8 @@ const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8";
|
||||
const CONFIG_HISTORY_PREFIX: &str = "config/history";
|
||||
const CONFIG_HISTORY_SUFFIX: &str = ".kv";
|
||||
const CONFIG_HISTORY_SNAPSHOT_HEADER: &str = "# rustfs-config-history: full-snapshot-v1";
|
||||
const CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER: &str = "# rustfs-config-history: snapshot-envelope-v2";
|
||||
const CONFIG_HISTORY_ENCRYPTED_HEADER: &str = "# rustfs-config-history: encrypted-full-snapshot-v2";
|
||||
const CONFIG_APPLIED_HEADER: &str = "x-rustfs-config-applied";
|
||||
const CONFIG_APPLIED_COMPAT_HEADER: &str = "x-minio-config-applied";
|
||||
const CONFIG_APPLIED_TRUE: &str = "true";
|
||||
@@ -166,6 +170,16 @@ struct ConfigHistoryEntry {
|
||||
create_time: OffsetDateTime,
|
||||
#[serde(rename = "Data", skip_serializing_if = "Option::is_none")]
|
||||
data: Option<String>,
|
||||
#[serde(rename = "DataRedacted", skip_serializing_if = "Option::is_none")]
|
||||
data_redacted: Option<bool>,
|
||||
}
|
||||
|
||||
impl ConfigHistoryEntry {
|
||||
fn set_redacted_data(&mut self, persisted: &[u8]) -> S3Result<()> {
|
||||
self.data = Some(redact_config_history_data(persisted)?);
|
||||
self.data_redacted = Some(true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_CLASS_HELP_KEYS: &[HelpKeyMetadata] = &[
|
||||
@@ -839,8 +853,8 @@ async fn save_server_config_history(data: &[u8]) -> S3Result<String> {
|
||||
/// this record while holding the server-config write lock, before persisting
|
||||
/// their replacement state. The version marker deliberately makes older
|
||||
/// directive-only entries distinguishable so restore can reject them safely.
|
||||
fn encode_config_history_snapshot(config: &ServerConfig) -> Vec<u8> {
|
||||
let rendered = render_full_config(config);
|
||||
fn encode_config_history_recovery(config: &ServerConfig) -> Vec<u8> {
|
||||
let rendered = render_full_config(config, false);
|
||||
let mut snapshot = Vec::with_capacity(CONFIG_HISTORY_SNAPSHOT_HEADER.len() + 1 + rendered.len());
|
||||
snapshot.extend_from_slice(CONFIG_HISTORY_SNAPSHOT_HEADER.as_bytes());
|
||||
snapshot.push(b'\n');
|
||||
@@ -848,8 +862,106 @@ fn encode_config_history_snapshot(config: &ServerConfig) -> Vec<u8> {
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn encode_config_history_snapshot(config: &ServerConfig) -> S3Result<Vec<u8>> {
|
||||
let recovery = encode_config_history_recovery(config);
|
||||
let display = render_full_config(config, true);
|
||||
let recovery_len =
|
||||
u64::try_from(recovery.len()).map_err(|_| s3_error!(InternalError, "config history recovery snapshot is too large"))?;
|
||||
let mut snapshot =
|
||||
Vec::with_capacity(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.len() + 1 + size_of::<u64>() + recovery.len() + display.len());
|
||||
snapshot.extend_from_slice(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.as_bytes());
|
||||
snapshot.push(b'\n');
|
||||
snapshot.extend_from_slice(&recovery_len.to_be_bytes());
|
||||
snapshot.extend_from_slice(&recovery);
|
||||
snapshot.extend_from_slice(&display);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn split_config_history_snapshot(data: &[u8]) -> S3Result<Option<(&[u8], &[u8])>> {
|
||||
let Some(envelope) = data.strip_prefix(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.as_bytes()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let envelope = envelope
|
||||
.strip_prefix(b"\n")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope header is malformed"))?;
|
||||
let (recovery_len, payload) = envelope
|
||||
.split_at_checked(size_of::<u64>())
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope is truncated"))?;
|
||||
let recovery_len = u64::from_be_bytes(
|
||||
recovery_len
|
||||
.try_into()
|
||||
.map_err(|_| s3_error!(InvalidRequest, "config history snapshot envelope length is malformed"))?,
|
||||
);
|
||||
let recovery_len = usize::try_from(recovery_len)
|
||||
.map_err(|_| s3_error!(InvalidRequest, "config history recovery snapshot length is unsupported"))?;
|
||||
let (recovery, display) = payload
|
||||
.split_at_checked(recovery_len)
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope recovery data is truncated"))?;
|
||||
Ok(Some((recovery, display)))
|
||||
}
|
||||
|
||||
fn config_history_encryption_key() -> S3Result<Vec<u8>> {
|
||||
if let Some(key) = rustfs_iam::keyring::encrypt_key() {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
current_action_credentials()
|
||||
.map(|credentials| credentials.secret_key.into_bytes())
|
||||
.filter(|key| !key.is_empty())
|
||||
.ok_or_else(|| s3_error!(InternalError, "config history encryption key is not initialized"))
|
||||
}
|
||||
|
||||
fn config_history_decryption_keys() -> S3Result<Vec<Vec<u8>>> {
|
||||
let mut keys = rustfs_iam::keyring::decrypt_keys();
|
||||
if let Some(key) = current_action_credentials()
|
||||
.map(|credentials| credentials.secret_key.into_bytes())
|
||||
.filter(|key| !key.is_empty())
|
||||
{
|
||||
keys.push(key);
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return Err(s3_error!(InternalError, "config history decryption key is not initialized"));
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn seal_config_history_snapshot(snapshot: &[u8], key: &[u8]) -> S3Result<Vec<u8>> {
|
||||
let ciphertext = rustfs_crypto::encrypt_stream_io(key, snapshot)
|
||||
.map_err(|err| s3_error!(InternalError, "failed to encrypt config history snapshot: {}", err))?;
|
||||
let mut sealed = Vec::with_capacity(CONFIG_HISTORY_ENCRYPTED_HEADER.len() + 1 + ciphertext.len());
|
||||
sealed.extend_from_slice(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes());
|
||||
sealed.push(b'\n');
|
||||
sealed.extend_from_slice(&ciphertext);
|
||||
Ok(sealed)
|
||||
}
|
||||
|
||||
fn open_config_history_data_with_keys(data: &[u8], keys: &[Vec<u8>]) -> S3Result<Vec<u8>> {
|
||||
let Some(ciphertext) = data.strip_prefix(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()) else {
|
||||
return Ok(data.to_vec());
|
||||
};
|
||||
let ciphertext = ciphertext
|
||||
.strip_prefix(b"\n")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "encrypted config history header is malformed"))?;
|
||||
|
||||
for key in keys {
|
||||
if let Ok(plaintext) = rustfs_crypto::decrypt_stream_io(key, ciphertext) {
|
||||
return Ok(plaintext);
|
||||
}
|
||||
}
|
||||
Err(s3_error!(InvalidRequest, "config history snapshot cannot be decrypted"))
|
||||
}
|
||||
|
||||
fn open_config_history_data(data: &[u8]) -> S3Result<Vec<u8>> {
|
||||
if data.starts_with(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()) {
|
||||
return open_config_history_data_with_keys(data, &config_history_decryption_keys()?);
|
||||
}
|
||||
Ok(data.to_vec())
|
||||
}
|
||||
|
||||
fn decode_config_history_snapshot(data: &[u8]) -> S3Result<ServerConfig> {
|
||||
let text = std::str::from_utf8(data).map_err(|_| s3_error!(InvalidRequest, "config history entry is not valid UTF-8"))?;
|
||||
let data = open_config_history_data(data)?;
|
||||
let recovery = split_config_history_snapshot(&data)?.map_or(data.as_slice(), |(recovery, _)| recovery);
|
||||
let text = std::str::from_utf8(recovery).map_err(|_| s3_error!(InvalidRequest, "config history entry is not valid UTF-8"))?;
|
||||
let Some(snapshot) = text.strip_prefix(CONFIG_HISTORY_SNAPSHOT_HEADER) else {
|
||||
return Err(s3_error!(
|
||||
InvalidRequest,
|
||||
@@ -879,7 +991,9 @@ fn validate_restore_rollback_generation(current: &ServerConfig, restored: &Serve
|
||||
}
|
||||
|
||||
async fn save_server_config_history_snapshot(config: &ServerConfig) -> S3Result<String> {
|
||||
save_server_config_history(&encode_config_history_snapshot(config)).await
|
||||
let snapshot = encode_config_history_snapshot(config)?;
|
||||
let sealed = seal_config_history_snapshot(&snapshot, &config_history_encryption_key()?)?;
|
||||
save_server_config_history(&sealed).await
|
||||
}
|
||||
|
||||
async fn cleanup_failed_config_history_snapshot(restore_id: &str) {
|
||||
@@ -939,25 +1053,11 @@ async fn list_server_config_history(with_data: bool, count: Option<usize>) -> S3
|
||||
continue;
|
||||
};
|
||||
|
||||
let data = if with_data {
|
||||
Some(
|
||||
String::from_utf8(
|
||||
read_admin_config(store.clone(), &object.name)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
.map_err(S3Error::from)?,
|
||||
)
|
||||
.map_err(ApiError::other)
|
||||
.map_err(S3Error::from)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
entries.push(ConfigHistoryEntry {
|
||||
restore_id,
|
||||
create_time: object.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH),
|
||||
data,
|
||||
data: None,
|
||||
data_redacted: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -971,7 +1071,17 @@ async fn list_server_config_history(with_data: bool, count: Option<usize>) -> S3
|
||||
}
|
||||
}
|
||||
|
||||
Ok(trim_history_entries(entries, count))
|
||||
let mut entries = trim_history_entries(entries, count);
|
||||
if with_data {
|
||||
for entry in &mut entries {
|
||||
let persisted = read_admin_config(store.clone(), &history_object_name(&entry.restore_id))
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
.map_err(S3Error::from)?;
|
||||
entry.set_redacted_data(&persisted)?;
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn normalize_target(target: &str) -> String {
|
||||
@@ -1159,6 +1269,99 @@ fn is_sensitive_key_name(key: &str) -> bool {
|
||||
|| normalized.ends_with("_private_key")
|
||||
}
|
||||
|
||||
fn is_sensitive_env_key_name(key: &str) -> bool {
|
||||
if is_sensitive_key_name(key) {
|
||||
return true;
|
||||
}
|
||||
let normalized = format!("_{}_", key.trim().to_ascii_lowercase());
|
||||
normalized.contains("_token_")
|
||||
|| normalized.contains("_client_key_")
|
||||
|| normalized.contains("_tls_client_key_")
|
||||
|| normalized.contains("_private_key_")
|
||||
}
|
||||
|
||||
fn is_sensitive_config_key(sub_system: &str, key: &str) -> bool {
|
||||
if is_sensitive_key_name(key) {
|
||||
return true;
|
||||
}
|
||||
if DEFAULT_KVS.get().is_none() {
|
||||
crate::admin::storage_api::config::init_admin_config_defaults();
|
||||
}
|
||||
DEFAULT_KVS
|
||||
.get()
|
||||
.and_then(|defaults| defaults.get(sub_system))
|
||||
.and_then(|kvs| kvs.0.iter().find(|entry| entry.key == key))
|
||||
.is_some_and(|entry| entry.hidden_if_empty)
|
||||
}
|
||||
|
||||
fn redact_config_history_line(line: &str) -> String {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let (comment_prefix, content) = match trimmed.strip_prefix('#') {
|
||||
Some(content) => ("# ", content.trim()),
|
||||
None => ("", trimmed),
|
||||
};
|
||||
let Ok(tokens) = tokenize_config_line(content) else {
|
||||
return format!("{comment_prefix}{REDACTED_VALUE}");
|
||||
};
|
||||
let Some(scope) = tokens.first() else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
if scope.contains('=') {
|
||||
let assignments = tokens
|
||||
.iter()
|
||||
.map(|token| {
|
||||
let Some((key, value)) = token.split_once('=') else {
|
||||
return REDACTED_VALUE.to_string();
|
||||
};
|
||||
let value = if is_sensitive_env_key_name(key) {
|
||||
REDACTED_VALUE
|
||||
} else {
|
||||
value
|
||||
};
|
||||
format_kv_pair(key, value)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
return format!("{comment_prefix}{}", assignments.join(" "));
|
||||
}
|
||||
|
||||
let sub_system = scope.split_once(':').map_or(scope.as_str(), |(sub_system, _)| sub_system);
|
||||
let mut redacted = Vec::with_capacity(tokens.len());
|
||||
redacted.push(scope.clone());
|
||||
for token in tokens.iter().skip(1) {
|
||||
let Some((key, value)) = token.split_once('=') else {
|
||||
redacted.push(token.clone());
|
||||
continue;
|
||||
};
|
||||
let value = if is_sensitive_config_key(sub_system, key) {
|
||||
REDACTED_VALUE
|
||||
} else {
|
||||
value
|
||||
};
|
||||
redacted.push(format_kv_pair(key, value));
|
||||
}
|
||||
format!("{comment_prefix}{}", redacted.join(" "))
|
||||
}
|
||||
|
||||
fn redact_config_history_data(data: &[u8]) -> S3Result<String> {
|
||||
let plaintext = open_config_history_data(data)?;
|
||||
if let Some((_, display)) = split_config_history_snapshot(&plaintext)? {
|
||||
return Ok(std::str::from_utf8(display).unwrap_or(REDACTED_VALUE).to_string());
|
||||
}
|
||||
let Ok(text) = std::str::from_utf8(&plaintext) else {
|
||||
return Ok(REDACTED_VALUE.to_string());
|
||||
};
|
||||
let text = text
|
||||
.strip_prefix(CONFIG_HISTORY_SNAPSHOT_HEADER)
|
||||
.and_then(|snapshot| snapshot.strip_prefix('\n'))
|
||||
.unwrap_or(text);
|
||||
Ok(text.lines().map(redact_config_history_line).collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
|
||||
fn apply_set_directives(config: &mut ServerConfig, directives: &[ConfigDirective]) -> S3Result<()> {
|
||||
for directive in directives {
|
||||
let targets = config.0.entry(directive.sub_system.clone()).or_default();
|
||||
@@ -1383,7 +1586,7 @@ fn render_selected_config(config: &ServerConfig, selector: &ConfigSelector, reda
|
||||
Ok(lines.join("\n").into_bytes())
|
||||
}
|
||||
|
||||
fn render_full_config(config: &ServerConfig) -> Vec<u8> {
|
||||
fn render_full_config(config: &ServerConfig, redact_secrets: bool) -> Vec<u8> {
|
||||
let mut subsystems = config.0.iter().collect::<Vec<_>>();
|
||||
subsystems.sort_by_key(|(lhs, _)| *lhs);
|
||||
|
||||
@@ -1393,7 +1596,7 @@ fn render_full_config(config: &ServerConfig) -> Vec<u8> {
|
||||
sorted_targets.sort_by_key(|(lhs, _)| *lhs);
|
||||
|
||||
for (target, kvs) in sorted_targets {
|
||||
if let Some(line) = render_scope_line(sub_system, target, kvs, false) {
|
||||
if let Some(line) = render_scope_line(sub_system, target, kvs, redact_secrets) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
@@ -2071,7 +2274,7 @@ impl Operation for GetConfigHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let cred = validate_config_admin_request(&req).await?;
|
||||
let config = load_active_server_config().await?;
|
||||
let payload = render_full_config(&config);
|
||||
let payload = render_full_config(&config, false);
|
||||
let (body, content_type) = encode_config_payload(req.uri.path(), &cred.secret_key, payload, TEXT_CONTENT_TYPE)?;
|
||||
response_with_content_type(StatusCode::OK, body, &content_type)
|
||||
}
|
||||
@@ -2128,7 +2331,9 @@ impl Operation for SetConfigHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use temp_env::with_vars;
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_config_commit_persists_before_publish() {
|
||||
@@ -2309,12 +2514,12 @@ identity_openid config_url="https://issuer.example" client_id="console""#,
|
||||
)
|
||||
.expect("apply original directives");
|
||||
|
||||
let exported = String::from_utf8(render_full_config(&original)).expect("utf8 export");
|
||||
let exported = String::from_utf8(render_full_config(&original, false)).expect("utf8 export");
|
||||
let mut restored = ServerConfig::new();
|
||||
apply_set_directives(&mut restored, &parse_config_directives(&exported, false).expect("parse exported config"))
|
||||
.expect("apply restored directives");
|
||||
|
||||
assert_eq!(render_full_config(&original), render_full_config(&restored));
|
||||
assert_eq!(render_full_config(&original, false), render_full_config(&restored, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2623,25 +2828,269 @@ notify_webhook:primary endpoint="https://hooks.example" auth_token="target-secre
|
||||
)
|
||||
.expect("apply fixture");
|
||||
|
||||
let encoded = encode_config_history_snapshot(&original);
|
||||
let encoded = encode_config_history_snapshot(&original).expect("encode snapshot");
|
||||
let restored = decode_config_history_snapshot(&encoded).expect("decode snapshot");
|
||||
|
||||
assert_eq!(render_full_config(&restored), render_full_config(&original));
|
||||
let rendered = String::from_utf8(render_full_config(&restored)).expect("utf8");
|
||||
assert_eq!(render_full_config(&restored, false), render_full_config(&original, false));
|
||||
let rendered = String::from_utf8(render_full_config(&restored, false)).expect("utf8");
|
||||
assert!(rendered.contains(r#"client_secret="unrelated-secret""#));
|
||||
assert!(rendered.contains(r#"notify_webhook:primary"#));
|
||||
assert!(rendered.contains(r#"auth_token="target-secret""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_history_snapshot_hides_secrets_and_rejects_wrong_key() {
|
||||
crate::admin::storage_api::config::init_admin_config_defaults();
|
||||
let mut config = ServerConfig::new();
|
||||
apply_set_directives(
|
||||
&mut config,
|
||||
&parse_config_directives(r#"identity_openid client_id="client" client_secret="snapshot-canary-secret""#, false)
|
||||
.expect("parse fixture"),
|
||||
)
|
||||
.expect("apply fixture");
|
||||
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
|
||||
|
||||
let sealed = seal_config_history_snapshot(&snapshot, b"history-test-key").expect("seal snapshot");
|
||||
|
||||
assert!(sealed.starts_with(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()));
|
||||
assert!(
|
||||
!sealed
|
||||
.windows(b"snapshot-canary-secret".len())
|
||||
.any(|window| window == b"snapshot-canary-secret")
|
||||
);
|
||||
let wrong_key_error =
|
||||
open_config_history_data_with_keys(&sealed, &[b"wrong-key".to_vec()]).expect_err("wrong key must fail closed");
|
||||
assert!(!wrong_key_error.to_string().contains("snapshot-canary-secret"));
|
||||
let opened = open_config_history_data_with_keys(&sealed, &[b"history-test-key".to_vec()]).expect("open snapshot");
|
||||
assert_eq!(opened, snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn history_snapshot_key_rotation_uses_current_and_old_iam_master_keys() {
|
||||
crate::admin::storage_api::config::init_admin_config_defaults();
|
||||
let mut config = ServerConfig::new();
|
||||
apply_set_directives(
|
||||
&mut config,
|
||||
&parse_config_directives(r#"storage_class standard="EC:2""#, false).expect("parse fixture"),
|
||||
)
|
||||
.expect("apply fixture");
|
||||
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
|
||||
let sealed = with_vars(
|
||||
[
|
||||
(rustfs_iam::keyring::ENV_IAM_MASTER_KEY, Some("history-master-old")),
|
||||
(rustfs_iam::keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None::<&str>),
|
||||
],
|
||||
|| {
|
||||
let key = config_history_encryption_key().expect("current history encryption key");
|
||||
seal_config_history_snapshot(&snapshot, &key).expect("seal with current key")
|
||||
},
|
||||
);
|
||||
|
||||
with_vars(
|
||||
[
|
||||
(rustfs_iam::keyring::ENV_IAM_MASTER_KEY, Some("history-master-new")),
|
||||
(
|
||||
rustfs_iam::keyring::ENV_IAM_MASTER_KEY_OLD_KEYS,
|
||||
Some("history-master-unused,history-master-old"),
|
||||
),
|
||||
],
|
||||
|| {
|
||||
let restored = decode_config_history_snapshot(&sealed).expect("restore with old-key fallback");
|
||||
assert_eq!(render_full_config(&restored, false), render_full_config(&config, false));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_snapshot_listing_redacts_known_and_fallback_secrets() {
|
||||
crate::admin::storage_api::config::init_admin_config_defaults();
|
||||
let mut config = ServerConfig::new();
|
||||
apply_set_directives(
|
||||
&mut config,
|
||||
&parse_config_directives(
|
||||
r#"identity_openid:primary client_id="client" client_secret="snapshot-client-canary"
|
||||
notify_webhook:primary endpoint="https://hooks.example" auth_token="snapshot-token-canary"
|
||||
notify_webhook:secondary endpoint="https://secondary.example" client_key="snapshot-key-canary""#,
|
||||
false,
|
||||
)
|
||||
.expect("parse fixture"),
|
||||
)
|
||||
.expect("apply fixture");
|
||||
|
||||
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
|
||||
let listed = redact_config_history_data(&snapshot).expect("redact snapshot");
|
||||
|
||||
assert!(listed.contains(r#"client_id="client""#));
|
||||
assert!(listed.contains(r#"endpoint="https://hooks.example""#));
|
||||
assert!(listed.contains(r#"client_secret="*redacted*""#));
|
||||
assert!(listed.contains(r#"auth_token="*redacted*""#));
|
||||
assert!(listed.contains(r#"client_key="*redacted*""#));
|
||||
assert!(!listed.contains("snapshot-client-canary"));
|
||||
assert!(!listed.contains("snapshot-token-canary"));
|
||||
assert!(!listed.contains("snapshot-key-canary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_snapshot_listing_preserves_creation_time_hidden_metadata() {
|
||||
crate::admin::storage_api::config::init_admin_config_defaults();
|
||||
let mut config = ServerConfig::new();
|
||||
let targets = config.0.entry(IDENTITY_OPENID_SUB_SYS.to_string()).or_default();
|
||||
targets.insert(
|
||||
DEFAULT_DELIMITER.to_string(),
|
||||
KVS(vec![KV {
|
||||
key: OIDC_CLIENT_ID.to_string(),
|
||||
value: "metadata-hidden-canary".to_string(),
|
||||
hidden_if_empty: true,
|
||||
}]),
|
||||
);
|
||||
|
||||
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
|
||||
let listed = redact_config_history_data(&snapshot).expect("redact snapshot");
|
||||
let restored = decode_config_history_snapshot(&snapshot).expect("decode snapshot");
|
||||
|
||||
assert!(listed.contains(r#"client_id="*redacted*""#));
|
||||
assert!(!listed.contains("metadata-hidden-canary"));
|
||||
assert!(
|
||||
String::from_utf8(render_full_config(&restored, false))
|
||||
.expect("utf8 restored config")
|
||||
.contains(r#"client_id="metadata-hidden-canary""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_v1_history_snapshot_remains_restorable_and_lists_redacted() {
|
||||
crate::admin::storage_api::config::init_admin_config_defaults();
|
||||
let mut config = ServerConfig::new();
|
||||
apply_set_directives(
|
||||
&mut config,
|
||||
&parse_config_directives(r#"identity_openid client_id="legacy-client" client_secret="plaintext-v1-canary""#, false)
|
||||
.expect("parse fixture"),
|
||||
)
|
||||
.expect("apply fixture");
|
||||
let snapshot = encode_config_history_recovery(&config);
|
||||
|
||||
let listed = redact_config_history_data(&snapshot).expect("redact v1 snapshot");
|
||||
let restored = decode_config_history_snapshot(&snapshot).expect("restore v1 snapshot");
|
||||
|
||||
assert!(listed.contains(r#"client_secret="*redacted*""#));
|
||||
assert!(!listed.contains("plaintext-v1-canary"));
|
||||
assert!(
|
||||
String::from_utf8(render_full_config(&restored, false))
|
||||
.expect("utf8 restored config")
|
||||
.contains(r#"client_secret="plaintext-v1-canary""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_history_listing_redacts_quotes_env_lines_and_multiple_targets() {
|
||||
let history = br#"identity_openid:primary client_id="visible-client" client_secret="quoted canary secret"
|
||||
notify_webhook:primary endpoint="https://hooks.example" auth_token="target-canary-token"
|
||||
notify_webhook:secondary endpoint="https://secondary.example" password="fallback-canary-password"
|
||||
# RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_PRIMARY="env-canary-token"
|
||||
RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="env-canary-secret""#;
|
||||
|
||||
let listed = redact_config_history_data(history).expect("redact legacy history");
|
||||
|
||||
assert!(listed.contains(r#"client_id="visible-client""#));
|
||||
assert!(listed.contains(r#"endpoint="https://hooks.example""#));
|
||||
assert!(listed.contains(r#"client_secret="*redacted*""#));
|
||||
assert!(listed.contains(r#"auth_token="*redacted*""#));
|
||||
assert!(listed.contains(r#"password="*redacted*""#));
|
||||
assert!(listed.contains(r#"# RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_PRIMARY="*redacted*""#));
|
||||
assert!(listed.contains(r#"RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="*redacted*""#));
|
||||
for canary in [
|
||||
"quoted canary secret",
|
||||
"target-canary-token",
|
||||
"fallback-canary-password",
|
||||
"env-canary-token",
|
||||
"env-canary-secret",
|
||||
] {
|
||||
assert!(!listed.contains(canary));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_legacy_history_listing_fails_closed_without_echoing_bytes() {
|
||||
let malformed = redact_config_history_data(b"identity_openid client_secret=\"unterminated-canary")
|
||||
.expect("malformed history is replaced");
|
||||
let binary =
|
||||
redact_config_history_data(&[0xff, 0xfe, b's', b'e', b'c', b'r', b'e', b't']).expect("binary history is replaced");
|
||||
|
||||
assert_eq!(malformed, REDACTED_VALUE);
|
||||
assert_eq!(binary, REDACTED_VALUE);
|
||||
assert!(!malformed.contains("unterminated-canary"));
|
||||
assert!(!binary.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_history_snapshot_envelope_fails_closed() {
|
||||
let mut truncated_length = format!("{CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER}\n").into_bytes();
|
||||
truncated_length.extend_from_slice(b"canary");
|
||||
let mut truncated_recovery = format!("{CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER}\n").into_bytes();
|
||||
truncated_recovery.extend_from_slice(&u64::MAX.to_be_bytes());
|
||||
truncated_recovery.extend_from_slice(b"envelope-canary");
|
||||
|
||||
for malformed in [truncated_length, truncated_recovery] {
|
||||
let error = redact_config_history_data(&malformed).expect_err("malformed envelope must fail closed");
|
||||
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert!(!error.to_string().contains("canary"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_response_marks_data_as_redacted_without_serializing_canary() {
|
||||
let mut entry = ConfigHistoryEntry {
|
||||
restore_id: "restore-id".to_string(),
|
||||
create_time: OffsetDateTime::UNIX_EPOCH,
|
||||
data: None,
|
||||
data_redacted: None,
|
||||
};
|
||||
entry
|
||||
.set_redacted_data(b"identity_openid client_secret=\"history-response-canary\"")
|
||||
.expect("redact response data");
|
||||
|
||||
let payload = serde_json::to_string(&entry).expect("serialize history entry");
|
||||
|
||||
assert!(payload.contains(r#""DataRedacted":true"#));
|
||||
assert!(payload.contains("*redacted*"));
|
||||
assert!(!payload.contains("history-response-canary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minio_compatible_history_response_encrypts_only_redacted_data() {
|
||||
let mut entry = ConfigHistoryEntry {
|
||||
restore_id: "restore-id".to_string(),
|
||||
create_time: OffsetDateTime::UNIX_EPOCH,
|
||||
data: None,
|
||||
data_redacted: None,
|
||||
};
|
||||
entry
|
||||
.set_redacted_data(b"identity_openid client_secret=\"compat-history-canary\"")
|
||||
.expect("redact response data");
|
||||
let payload = serde_json::to_vec(&[entry]).expect("serialize history response");
|
||||
|
||||
let (encoded, content_type) =
|
||||
encode_config_payload("/minio/admin/v3/list-config-history-kv", "compat-test-key", payload, JSON_CONTENT_TYPE)
|
||||
.expect("encode compatible response");
|
||||
let decoded = rustfs_crypto::decrypt_stream_io(b"compat-test-key", &encoded).expect("decrypt compatible response");
|
||||
let decoded = String::from_utf8(decoded).expect("utf8 response");
|
||||
|
||||
assert_eq!(content_type, OCTET_STREAM_CONTENT_TYPE);
|
||||
assert!(decoded.contains(r#""DataRedacted":true"#));
|
||||
assert!(decoded.contains("*redacted*"));
|
||||
assert!(!decoded.contains("compat-history-canary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_snapshot_supports_complete_default_config() {
|
||||
crate::admin::storage_api::config::init_admin_config_defaults();
|
||||
let original = ServerConfig::new();
|
||||
|
||||
let encoded = encode_config_history_snapshot(&original);
|
||||
let encoded = encode_config_history_snapshot(&original).expect("encode snapshot");
|
||||
let restored = decode_config_history_snapshot(&encoded).expect("decode default snapshot");
|
||||
|
||||
assert_eq!(render_full_config(&restored), render_full_config(&original));
|
||||
assert_eq!(render_full_config(&restored, false), render_full_config(&original, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2658,7 +3107,7 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second
|
||||
.expect("parse before fixture"),
|
||||
)
|
||||
.expect("apply before fixture");
|
||||
let snapshot = encode_config_history_snapshot(&before);
|
||||
let snapshot = encode_config_history_snapshot(&before).expect("encode snapshot");
|
||||
|
||||
let mut after = before.clone();
|
||||
apply_set_directives(
|
||||
@@ -2672,8 +3121,8 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second
|
||||
);
|
||||
|
||||
let restored = decode_config_history_snapshot(&snapshot).expect("restore pre-change snapshot");
|
||||
assert_eq!(render_full_config(&restored), render_full_config(&before));
|
||||
assert_ne!(render_full_config(&restored), render_full_config(&after));
|
||||
assert_eq!(render_full_config(&restored, false), render_full_config(&before, false));
|
||||
assert_ne!(render_full_config(&restored, false), render_full_config(&after, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2718,16 +3167,19 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second
|
||||
restore_id: "oldest".to_string(),
|
||||
create_time: OffsetDateTime::from_unix_timestamp(1).expect("timestamp"),
|
||||
data: None,
|
||||
data_redacted: None,
|
||||
},
|
||||
ConfigHistoryEntry {
|
||||
restore_id: "middle".to_string(),
|
||||
create_time: OffsetDateTime::from_unix_timestamp(2).expect("timestamp"),
|
||||
data: None,
|
||||
data_redacted: None,
|
||||
},
|
||||
ConfigHistoryEntry {
|
||||
restore_id: "newest".to_string(),
|
||||
create_time: OffsetDateTime::from_unix_timestamp(3).expect("timestamp"),
|
||||
data: None,
|
||||
data_redacted: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2832,7 +3284,7 @@ identity_openid client_id="existing-client""#,
|
||||
.expect("apply");
|
||||
|
||||
// Simulate "mc admin config export" (render_full_config)
|
||||
let full_output = String::from_utf8(render_full_config(&config)).expect("utf8");
|
||||
let full_output = String::from_utf8(render_full_config(&config, false)).expect("utf8");
|
||||
|
||||
// Simulate "mc admin config get storage_class" (target=None, redact=true)
|
||||
let selected_output = String::from_utf8(
|
||||
|
||||
Reference in New Issue
Block a user