feat(replication): support temporary target credentials (#6860)

This commit is contained in:
cxymds
2026-08-30 08:44:34 +08:00
committed by GitHub
parent ff3ad30f0c
commit 1e8c8d4cd5
12 changed files with 792 additions and 219 deletions
+130 -98
View File
@@ -23,9 +23,9 @@ use crate::admin::storage_api::bucket::metadata::BUCKET_TARGETS_FILE;
use crate::admin::storage_api::bucket::metadata_sys;
use crate::admin::storage_api::bucket::metadata_sys::get_replication_config;
use crate::admin::storage_api::bucket::replication::REMOTE_TARGET_UNSUPPORTED_FIELDS;
use crate::admin::storage_api::bucket::replication::{BucketStats, ReplicationStatusType};
#[cfg(test)]
use crate::admin::storage_api::bucket::replication::{REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS};
use crate::admin::storage_api::bucket::replication::REMOTE_TARGET_WRITABLE_FIELDS;
use crate::admin::storage_api::bucket::replication::{BucketStats, ReplicationStatusType};
use crate::admin::storage_api::bucket::target::{
BucketTarget, BucketTargetType, Credentials as TargetCredentials, LatencyStat, duration_from_secs_or_nanos,
};
@@ -57,13 +57,6 @@ use url::Host;
const SUPPORTED_REMOTE_TARGET_API: &str = "s3v4";
/// Go encodes the zero `time.Time` as the year-1 instant
/// (`0001-01-01T00:00:00Z`, possibly re-encoded with an offset); no real
/// credential expiry lives in year 1, so any such timestamp means "unset".
fn is_go_zero_time(timestamp: Timestamp) -> bool {
timestamp.to_zoned(jiff::tz::TimeZone::UTC).year() == 1
}
/// Field groups a `set-remote-target?update=true` request may modify, mirroring
/// MinIO's `TargetUpdateType` / `GetTargetUpdateOps` query contract: the update
/// overlays only the requested groups onto the stored target, so a client can
@@ -250,7 +243,7 @@ impl RemoteTargetRequest {
fn into_bucket_target(self) -> S3Result<BucketTarget> {
self.validate_connection_fields()?;
self.into_bucket_target_common()
self.into_bucket_target_common(true)
}
/// Partial-update parse: only the field groups named by `ops` are validated;
@@ -259,43 +252,18 @@ impl RemoteTargetRequest {
if self.arn.trim().is_empty() {
return Err(s3_error!(InvalidRequest, "arn is required for update"));
}
if ops.contains(&TargetUpdateOp::Credentials) {
let replacing_credentials = ops.contains(&TargetUpdateOp::Credentials);
if replacing_credentials {
self.validate_connection_fields()?;
}
self.into_bucket_target_common()
self.into_bucket_target_common(replacing_credentials)
}
fn into_bucket_target_common(self) -> S3Result<BucketTarget> {
fn into_bucket_target_common(self, validate_credentials: bool) -> S3Result<BucketTarget> {
if !self.target_type.is_valid() {
return Err(s3_error!(InvalidRequest, "type is invalid"));
}
if self
.credentials
.session_token
.as_deref()
.is_some_and(|token| !token.trim().is_empty())
{
return Err(s3_error!(
InvalidRequest,
"remote target field credentials.session_token is not supported by this RustFS version"
));
}
// Go's `omitempty` never elides a zero `time.Time`, so every madmin
// marshal carries `"expiration":"0001-01-01T00:00:00Z"`; only a real
// (non-year-1) expiry means the client wants expiring credentials.
if self
.credentials
.expiration
.is_some_and(|expiration| !is_go_zero_time(expiration))
{
return Err(s3_error!(
InvalidRequest,
"remote target field credentials.expiration is not supported by this RustFS version"
));
}
if !self.api.is_empty() && self.api != SUPPORTED_REMOTE_TARGET_API {
return Err(s3_error!(
InvalidRequest,
@@ -317,9 +285,17 @@ impl RemoteTargetRequest {
}
let mut credentials = TargetCredentials::from(self.credentials);
// Past the check above the expiration can only be the zero-value
// sentinel, i.e. "no expiration" — never persist it.
credentials.expiration = None;
credentials.expiration = credentials.effective_expiration();
if validate_credentials && credentials.expiration.is_some() && credentials.effective_session_token().is_none() {
return Err(s3_error!(InvalidRequest, "credentials.expiration requires credentials.session_token"));
}
if validate_credentials
&& credentials
.expiration
.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Err(s3_error!(InvalidRequest, "credentials.expiration must be in the future"));
}
Ok(BucketTarget {
source_bucket: self.source_bucket,
@@ -1504,10 +1480,10 @@ impl Operation for ReplicationMrfHandler {
#[cfg(test)]
mod tests {
use super::{
REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS,
RemoteTargetCredentialsRequest, RemoteTargetRequest, ReplicationDiffEntry, SUPPORTED_REMOTE_TARGET_API, TargetUpdateOp,
build_mrf_response, extract_query_params, parse_remote_target_update_ops, render_mrf_backlog, render_replication_diff,
unique_replication_peers, validate_remote_target_tls_settings,
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, RemoteTargetCredentialsRequest, RemoteTargetRequest,
ReplicationDiffEntry, SUPPORTED_REMOTE_TARGET_API, TargetUpdateOp, build_mrf_response, extract_query_params,
parse_remote_target_update_ops, render_mrf_backlog, render_replication_diff, unique_replication_peers,
validate_remote_target_tls_settings,
};
use crate::admin::storage_api::bucket::target::{BucketTarget, Credentials as TargetCredentials, LatencyStat};
use crate::admin::storage_api::replication::{BucketStats, DurableMrfBacklog, MrfOpKind, MrfReplicateEntry};
@@ -2104,27 +2080,13 @@ mod tests {
#[test]
fn remote_target_request_rejects_unimplemented_fields() {
for (field, value, historical_field) in [
(
"credentials.session_token",
serde_json::json!("session-token"),
Some("credentials.sessionToken"),
),
(
"credentials.expiration",
serde_json::json!("2026-01-01T00:00:00Z"),
Some("credentials.expiration"),
),
("api", serde_json::json!("s3v2"), None),
("edge", serde_json::json!(true), None),
("edgeSyncBeforeExpiry", serde_json::json!(true), None),
for (field, value) in [
("api", serde_json::json!("s3v2")),
("edge", serde_json::json!(true)),
("edgeSyncBeforeExpiry", serde_json::json!(true)),
] {
let mut request = valid_remote_target_request();
if let Some((credential_field, credential_name)) = field.split_once('.') {
request[credential_field][credential_name] = value;
} else {
request[field] = value;
}
request[field] = value;
let request: RemoteTargetRequest =
serde_json::from_value(request).expect("unsupported field should still deserialize");
let err = request
@@ -2133,15 +2095,86 @@ mod tests {
assert!(err.to_string().contains(field));
assert!(err.to_string().contains("not supported by this RustFS version"));
if let Some(historical_field) = historical_field {
assert!(
REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS.contains(&historical_field),
"rejected field {field} must be advertised as historical-only"
);
}
}
}
#[test]
fn remote_target_request_accepts_temporary_credentials() {
let mut request = valid_remote_target_request();
request["credentials"]["sessionToken"] = serde_json::json!("session-token");
request["credentials"]["expiration"] = serde_json::json!("2099-01-01T00:00:00Z");
let target = serde_json::from_value::<RemoteTargetRequest>(request)
.expect("temporary credentials should deserialize")
.into_bucket_target()
.expect("unexpired temporary credentials should be accepted");
let credentials = target.credentials.expect("credentials should be preserved");
assert_eq!(credentials.session_token.as_deref(), Some("session-token"));
assert_eq!(
serde_json::to_value(credentials.expiration.expect("expiration should be preserved"))
.expect("expiration should serialize"),
serde_json::json!("2099-01-01T00:00:00Z")
);
for field in ["credentials.sessionToken", "credentials.expiration"] {
assert!(REMOTE_TARGET_WRITABLE_FIELDS.contains(&field));
}
}
#[test]
fn remote_target_request_accepts_session_token_without_expiration() {
let mut request = valid_remote_target_request();
request["credentials"]["session_token"] = serde_json::json!("session-token");
let target = serde_json::from_value::<RemoteTargetRequest>(request)
.expect("temporary credentials should deserialize")
.into_bucket_target()
.expect("a session token without a reported expiration should remain compatible");
assert_eq!(
target
.credentials
.as_ref()
.and_then(|credentials| credentials.session_token.as_deref()),
Some("session-token")
);
assert!(target.credentials.and_then(|credentials| credentials.expiration).is_none());
}
#[test]
fn remote_target_request_rejects_expiration_without_session_token() {
let mut request = valid_remote_target_request();
request["credentials"]["expiration"] = serde_json::json!("2099-01-01T00:00:00Z");
let err = serde_json::from_value::<RemoteTargetRequest>(request)
.expect("request should deserialize")
.into_bucket_target()
.expect_err("an expiring credential bundle requires a session token");
assert!(
err.to_string()
.contains("credentials.expiration requires credentials.session_token")
);
}
#[test]
fn remote_target_request_rejects_expired_temporary_credentials_without_leaking_secrets() {
let mut request = valid_remote_target_request();
request["credentials"]["secretKey"] = serde_json::json!("secret-must-not-leak");
request["credentials"]["sessionToken"] = serde_json::json!("session-token-must-not-leak");
request["credentials"]["expiration"] = serde_json::json!("2000-01-01T00:00:00Z");
let err = serde_json::from_value::<RemoteTargetRequest>(request)
.expect("request should deserialize")
.into_bucket_target()
.expect_err("expired credentials must fail before persistence");
let message = err.to_string();
assert!(message.contains("credentials.expiration must be in the future"));
assert!(!message.contains("secret-must-not-leak"));
assert!(!message.contains("session-token-must-not-leak"));
}
#[test]
fn remote_target_request_accepts_real_madmin_add_marshal() {
let request: RemoteTargetRequest =
@@ -2192,6 +2225,32 @@ mod tests {
);
}
#[test]
fn non_credential_update_accepts_redacted_temporary_credential_round_trip() {
// list-remote-targets redacts both the secret and session token but
// intentionally retains the non-secret expiration. mc echoes that
// shape on a sync-only update; the credentials group is not applied.
let body = serde_json::json!({
"endpoint": "192.168.1.10:9000",
"credentials": {
"accessKey": "access",
"expiration": "2099-01-01T00:00:00Z"
},
"targetbucket": "target",
"arn": "arn:rustfs:replication:us-east-1:dep:target",
"type": "replication",
"replicationSync": true
});
let target = serde_json::from_value::<RemoteTargetRequest>(body)
.expect("redacted mc round-trip should deserialize")
.into_update_bucket_target(&[TargetUpdateOp::Sync])
.expect("a sync-only update must ignore the redacted credential group");
assert!(target.replication_sync);
assert!(target.credentials.and_then(|credentials| credentials.expiration).is_some());
}
#[test]
fn remote_target_request_ignores_client_supplied_latency() {
// Latency is a server-measured runtime stat; mc echoes the
@@ -2238,22 +2297,6 @@ mod tests {
assert!(!target.ca_cert_pem.is_empty());
}
#[test]
fn remote_target_request_validation_does_not_echo_credential_values() {
let mut request = valid_remote_target_request();
request["credentials"]["session_token"] = serde_json::json!("session-token-must-not-leak");
let request: RemoteTargetRequest = serde_json::from_value(request).expect("request should deserialize");
let err = request
.into_bucket_target()
.expect_err("session tokens must be rejected before persistence");
let message = err.to_string();
assert!(message.contains("credentials.session_token"));
assert!(!message.contains("session-token-must-not-leak"));
assert!(!message.contains("secret"));
}
#[test]
fn remote_target_request_accepts_go_duration_wire_values() {
// `mc replicate add` defaults `--healthcheck-seconds` to 60; madmin
@@ -2514,17 +2557,6 @@ mod tests {
#[test]
fn remote_target_capability_fields_do_not_overlap() {
for field in REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS {
assert!(
!REMOTE_TARGET_WRITABLE_FIELDS.contains(field),
"remote target field {field} cannot be both historical-only and writable"
);
assert!(
!REMOTE_TARGET_UNSUPPORTED_FIELDS.contains(field),
"remote target field {field} cannot be both historical-only and unsupported"
);
}
for field in REMOTE_TARGET_UNSUPPORTED_FIELDS {
assert!(
!REMOTE_TARGET_WRITABLE_FIELDS.contains(field),
+7 -17
View File
@@ -24,9 +24,8 @@ use crate::admin::runtime_sources::{
DefaultAdminUsecase, QueryServerInfoRequest, current_endpoints_handle, default_admin_usecase, object_store_from_req,
};
use crate::admin::storage_api::bucket::replication::{
REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, REMOTE_TARGET_UNSUPPORTED_FIELDS,
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
REPLICATION_WRITABLE_FIELDS,
REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS,
REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
};
use crate::admin::storage_api::cluster::{
CapabilityState, CapabilityStatus, ObservabilitySnapshotProvider, TopologySnapshot, TopologySnapshotProvider,
@@ -730,15 +729,6 @@ impl ReplicationCapabilities {
name,
state: ReplicationFieldState::Supported,
})
.chain(
REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS
.iter()
.copied()
.map(|name| ReplicationFieldCapability {
name,
state: ReplicationFieldState::ReadOnlyHistorical,
}),
)
.chain(
REMOTE_TARGET_UNSUPPORTED_FIELDS
.iter()
@@ -1306,7 +1296,7 @@ mod tests {
assert_eq!(response.summary.manual_transition_jobs.state, CapabilityState::Supported);
assert_eq!(response.replication.contract_version, 1);
assert_eq!(response.replication.bucket_replication.contract_version, 1);
// v3: temporary-credential fields are explicitly historical-only.
// v3: temporary-credential fields are writable and used for signing.
assert_eq!(response.replication.remote_targets.contract_version, 3);
assert_eq!(response.replication.bucket_replication.status.state, CapabilityState::Supported);
assert_eq!(response.replication.remote_targets.status.state, CapabilityState::Supported);
@@ -1363,8 +1353,8 @@ mod tests {
.remote_targets
.fields
.iter()
.any(|field| field.name == name && field.state == super::ReplicationFieldState::ReadOnlyHistorical),
"remote target field {name} must be advertised as historical-only"
.any(|field| field.name == name && field.state == super::ReplicationFieldState::Supported),
"remote target field {name} must be advertised as writable"
);
}
assert_eq!(response.manual_transition_jobs.contract_version, 1);
@@ -1469,8 +1459,8 @@ mod tests {
.as_array()
.expect("remote target fields should be an array")
.iter()
.any(|field| field["name"] == name && field["state"] == "read_only_historical"),
"serialized remote target field {name} must be historical-only"
.any(|field| field["name"] == name && field["state"] == "supported"),
"serialized remote target field {name} must be writable"
);
}
assert_eq!(value["manual_transition_jobs"]["contract_version"], 1);
+4 -4
View File
@@ -443,10 +443,10 @@ pub(crate) mod quota {
pub(crate) mod replication {
pub(crate) use super::ecstore_bucket::replication::{
OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS,
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION,
REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities,
merge_incoming_replication_config, replication_target_arn_deployment_id,
OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, merge_incoming_replication_config,
replication_target_arn_deployment_id,
};
pub(crate) type BucketReplicationResyncStatus = super::ecstore_bucket::replication::BucketReplicationResyncStatus;
pub(crate) type BucketStats = super::ecstore_bucket::replication::BucketStats;