feat(replication): replicate managed-SSE objects via target re-encryption (#5885)

Open the managed-SSE replication gate (backlog#1783, PR-B of 3, after
#5872): the replication reader already decrypts through the injected
object-encryption resolver, so the source sends plaintext plus an
encryption intent header (AES256 / aws:kms, never the source key id) and
the target re-encrypts on its normal PUT path with its own KMS. No DEK
crosses sites.

- replication_put_object_options: fail closed only on Unsupported;
  insert the SSE intent after the strip loop.
- TargetClient::create_multipart_upload sends the full opts.header()
  set, fixing multipart replicas losing content-type/user metadata
  (plaintext included).
- Preserve source ETag and mtime on replicas (authorized replication
  only): receiver wires x-rustfs-source-etag into preserve_etag for PUT
  and CompleteMultipartUpload, resolve_complete_etag consumes it, and
  complete options carry source_etag/source_mtime (absent mtime
  degrades to epoch, not now_utc). Without this every replication HEAD
  comparison re-drives re-encrypted objects forever.
- e2e: managed SSE contracts flip to success on an independent-KMS
  dual-process pair (byte-identical plain GET proves target-owned
  envelopes; ETag/mtime preserved; version stable across scanner
  cycles; resync converges; multipart keeps structure and metadata);
  new target-without-KMS fail-closed contract; SSE-C stays FAILED.

Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
唐小鸭
2026-08-09 18:21:35 +08:00
committed by GitHub
parent a71726ef49
commit 73e4ef4dd4
8 changed files with 408 additions and 125 deletions
+26 -4
View File
@@ -18,8 +18,8 @@ use http::header::{IF_MATCH, IF_NONE_MATCH};
use http::{HeaderMap, HeaderValue};
use rustfs_utils::http::{
AMZ_BUCKET_REPLICATION_STATUS, SUFFIX_FORCE_DELETE, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC,
SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID, get_header,
insert_header_map,
SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_ETAG, SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_REQUEST,
SUFFIX_SOURCE_VERSION_ID, get_header, insert_header_map,
metadata_compat::{MINIO_INTERNAL_PREFIX, RUSTFS_INTERNAL_PREFIX},
};
use rustfs_utils::http::{
@@ -352,9 +352,11 @@ pub fn get_complete_multipart_upload_opts_with_replication_authorization(
let mut replication_request = false;
let mut mod_time = None;
let mut preserve_etag = None;
if replication_request_authorized && get_header(headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true") {
replication_request = true;
mod_time = replication_source_mtime(headers);
preserve_etag = replication_source_etag(headers);
if let Some(actual_size_str) = get_header(headers, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE) {
rustfs_utils::http::insert_str(
&mut user_defined,
@@ -375,6 +377,7 @@ pub fn get_complete_multipart_upload_opts_with_replication_authorization(
user_defined,
replication_request,
mod_time,
preserve_etag,
..Default::default()
};
apply_replica_status_from_headers(headers, &mut opts, replication_request_authorized);
@@ -423,10 +426,20 @@ pub fn put_opts_from_headers_with_replication_authorization(
if replication_request_authorized && get_header(headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true") {
opts.replication_request = true;
opts.mod_time = replication_source_mtime(headers);
opts.preserve_etag = replication_source_etag(headers);
}
Ok(opts)
}
/// Replicas must keep the source object's ETag: managed-SSE replication
/// re-encrypts on the target, so a recomputed ETag would differ from the
/// source and every HEAD comparison would re-schedule the object forever.
fn replication_source_etag(headers: &HeaderMap<HeaderValue>) -> Option<String> {
let value = get_header(headers, SUFFIX_SOURCE_ETAG)?;
let value = value.trim().trim_matches('"');
(!value.is_empty()).then(|| value.to_string())
}
fn replication_source_mtime(headers: &HeaderMap<HeaderValue>) -> Option<time::OffsetDateTime> {
let value = get_header(headers, SUFFIX_SOURCE_MTIME)?;
let value = value.trim();
@@ -1026,8 +1039,8 @@ mod tests {
use http::{HeaderMap, HeaderValue};
use rustfs_utils::http::{
AMZ_BUCKET_REPLICATION_STATUS, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, SUFFIX_FORCE_DELETE, SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_MTIME,
SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID, insert_header,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, SUFFIX_FORCE_DELETE, SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_ETAG,
SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID, insert_header,
};
use s3s::S3ErrorCode;
use s3s::dto::{BucketVersioningStatus, ExcludedPrefix, VersioningConfiguration};
@@ -1443,6 +1456,7 @@ mod tests {
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
let valid_mtime = "2024-05-20T10:30:00+08:00";
insert_header(&mut headers, SUFFIX_SOURCE_MTIME, valid_mtime);
insert_header(&mut headers, SUFFIX_SOURCE_ETAG, "0123456789abcdef0123456789abcdef");
let metadata = HashMap::new();
@@ -1453,6 +1467,7 @@ mod tests {
assert!(!opts.replication_request);
assert!(opts.mod_time.is_none());
assert!(opts.preserve_etag.is_none());
}
#[test]
@@ -1461,11 +1476,15 @@ mod tests {
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
let valid_mtime = "2024-05-20T10:30:00+08:00";
insert_header(&mut headers, SUFFIX_SOURCE_MTIME, valid_mtime);
insert_header(&mut headers, SUFFIX_SOURCE_ETAG, "\"0123456789abcdef0123456789abcdef-3\"");
let opts = put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true)
.expect("authorized replication request should parse");
assert!(opts.replication_request);
// The replica keeps the source ETag verbatim (quotes trimmed) so the
// replication HEAD comparison converges after target-side re-encryption.
assert_eq!(opts.preserve_etag.as_deref(), Some("0123456789abcdef0123456789abcdef-3"));
let expected_mtime = time::OffsetDateTime::parse(valid_mtime, &time::format_description::well_known::Rfc3339).unwrap();
assert_eq!(opts.mod_time, Some(expected_mtime));
@@ -1578,11 +1597,13 @@ mod tests {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
insert_header(&mut headers, SUFFIX_SOURCE_MTIME, source_mtime);
insert_header(&mut headers, SUFFIX_SOURCE_ETAG, "\"0123456789abcdef0123456789abcdef-3\"");
let untrusted = get_complete_multipart_upload_opts(&headers)
.expect("ordinary multipart completion options should ignore replication headers");
assert!(!untrusted.replication_request);
assert!(untrusted.mod_time.is_none());
assert!(untrusted.preserve_etag.is_none());
let authorized = get_complete_multipart_upload_opts_with_replication_authorization(&headers, true)
.expect("authorized multipart replication options should parse");
@@ -1590,6 +1611,7 @@ mod tests {
.expect("test source mtime should be valid");
assert!(authorized.replication_request);
assert_eq!(authorized.mod_time, Some(expected));
assert_eq!(authorized.preserve_etag.as_deref(), Some("0123456789abcdef0123456789abcdef-3"));
insert_header(&mut headers, SUFFIX_SOURCE_MTIME, "invalid-time");
let invalid = get_complete_multipart_upload_opts_with_replication_authorization(&headers, true)