feat: support object lock remaining retention days (#8407)

This commit is contained in:
Valentin Belyakov
2026-10-08 11:52:27 +03:00
committed by GitHub
parent 3e87004032
commit 89a277c3d9
8 changed files with 297 additions and 5 deletions
@@ -198,6 +198,9 @@ pub enum S3KeyName {
#[strum(serialize = "s3:object-lock-retain-until-date")]
S3ObjectLockRetainUntilDate,
#[strum(serialize = "s3:object-lock-remaining-retention-days")]
S3ObjectLockRemainingRetentionDays,
#[strum(serialize = "s3:object-lock-legal-hold")]
S3ObjectLockLegalHold,
@@ -393,6 +396,10 @@ mod tests {
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("s3:object-lock-legal-hold", KeyName::S3(S3KeyName::S3ObjectLockLegalHold))]
#[test_case(
"s3:object-lock-remaining-retention-days",
KeyName::S3(S3KeyName::S3ObjectLockRemainingRetentionDays)
)]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -417,6 +424,10 @@ mod tests {
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("s3:object-lock-legal-hold", KeyName::S3(S3KeyName::S3ObjectLockLegalHold))]
#[test_case(
"s3:object-lock-remaining-retention-days",
KeyName::S3(S3KeyName::S3ObjectLockRemainingRetentionDays)
)]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -437,6 +448,10 @@ mod tests {
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId))]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("s3:object-lock-legal-hold", KeyName::S3(S3KeyName::S3ObjectLockLegalHold))]
#[test_case(
"s3:object-lock-remaining-retention-days",
KeyName::S3(S3KeyName::S3ObjectLockRemainingRetentionDays)
)]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -104,6 +104,7 @@ mod tests {
key_name::KeyName::{self, *},
key_name::S3KeyName::*,
};
use std::collections::HashMap;
use test_case::test_case;
fn new_func(name: KeyName, variable: Option<String>, value: i64) -> NumberFunc {
@@ -125,6 +126,35 @@ mod tests {
Ok(())
}
#[test_case(r#"{"s3:object-lock-remaining-retention-days": 30}"#; "number")]
#[test_case(r#"{"s3:object-lock-remaining-retention-days": "30"}"#; "string")]
fn remaining_retention_days_deserializes(input: &str) -> Result<(), serde_json::Error> {
let v: NumberFunc = serde_json::from_str(input)?;
assert_eq!(v, new_func(S3(S3ObjectLockRemainingRetentionDays), None, 30));
Ok(())
}
#[test_case("29", true, false, false; "below limit")]
#[test_case("30", true, false, true; "at limit")]
#[test_case("31", false, true, false; "above limit")]
fn remaining_retention_days_numeric_comparisons(days: &str, less_than_equals: bool, greater_than: bool, equals: bool) {
let limit = new_func(S3(S3ObjectLockRemainingRetentionDays), None, 30);
let values = HashMap::from([("object-lock-remaining-retention-days".to_string(), vec![days.to_string()])]);
assert_eq!(limit.evaluate(i64::le, false, &values), less_than_equals);
assert_eq!(limit.evaluate(i64::gt, false, &values), greater_than);
assert_eq!(limit.evaluate(i64::eq, false, &values), equals);
}
#[test]
fn remaining_retention_days_numeric_condition_without_value_uses_if_exists() {
let limit = new_func(S3(S3ObjectLockRemainingRetentionDays), None, 30);
let values = HashMap::new();
assert!(!limit.evaluate(i64::le, false, &values));
assert!(limit.evaluate(i64::le, true, &values));
}
#[test_case(r#"{"s3:max-keys":"1"}"#, new_func(S3(S3MaxKeys), None, 1); "1")]
#[test_case(r#"{"s3:max-keys/a":"1"}"#, new_func(S3(S3MaxKeys), Some("a".into()), 1); "2")]
fn test_ser(expect: &str, input: NumberFunc) -> Result<(), serde_json::Error> {
+2
View File
@@ -2452,6 +2452,8 @@ impl DefaultObjectUsecase {
explicit_version_id,
opts.delete_marker_replication_status() == ReplicationStatusType::Replica,
);
// auth_req inherits the outer request's date; a PAX override replaces it.
set_requested_object_lock_retain_until(&mut auth_req, effective_object_lock_retain_until_date.as_ref());
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectAction)).await);
if iam_requirements.tagging {
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectTaggingAction)).await);
+1 -1
View File
@@ -24,7 +24,7 @@ use super::storage_api::object_usecase::access::{
apply_copy_source_bucket_generation_guard, authorize_request, delete_object_authorize_action, has_bypass_governance_header,
load_bucket_generation_from_store, odm_read_generation, prepare_odm_read_generation,
recursive_force_delete_has_authenticated_caller, replication_request_authorized, req_info_mut, req_info_ref,
retained_table_data_plane_publication_guards,
retained_table_data_plane_publication_guards, set_requested_object_lock_retain_until,
};
#[cfg(test)]
use super::storage_api::object_usecase::bucket::quota::BucketQuota;
+1
View File
@@ -277,6 +277,7 @@ pub(crate) mod access {
load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
prepare_list_buckets_iam_authorization, prepare_odm_read_generation, recursive_force_delete_has_authenticated_caller,
replication_request_authorized, req_info_mut, req_info_ref, retained_table_data_plane_publication_guards,
set_requested_object_lock_retain_until,
};
}
+16
View File
@@ -824,6 +824,10 @@ pub fn get_condition_values_with_query_and_client_info(
args
}
/// Condition name for `s3:object-lock-remaining-retention-days`. Storage access
/// derives it from the requested retain-until date, so no header may set it.
pub(crate) const OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION: &str = "object-lock-remaining-retention-days";
/// Whether a request header is forbidden from contributing to policy condition key
/// `key`, either because the server already derived that key from verified state or
/// because it is a well-known identity/context key that only the server may populate.
@@ -836,6 +840,7 @@ fn is_reserved_condition_key(key: &str, server_derived: &HashMap<String, Vec<Str
]
.iter()
.any(|header| key.eq_ignore_ascii_case(header.trim_start_matches("x-amz-")))
|| key.eq_ignore_ascii_case(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION)
|| is_server_derived_condition_key(key)
}
@@ -2004,6 +2009,17 @@ mod tests {
assert_eq!(conditions.get("object-lock-retain-until-date"), None);
}
#[test]
fn object_lock_remaining_retention_days_header_is_ignored() {
let cred = create_test_credentials();
let mut headers = HeaderMap::new();
headers.insert(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION, HeaderValue::from_static("1"));
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION), None);
}
#[test]
fn test_get_condition_values_with_grant_headers() {
let cred = create_test_credentials();
+231 -4
View File
@@ -16,10 +16,10 @@ use super::ObjectOptions;
use super::ecfs::FS;
use super::{ECStore, PolicySys, ReplicationStatusType, StorageError, get_lock_acquire_timeout, is_err_bucket_not_found};
use crate::auth::{
AuthType, RUSTFS_MAX_CONTENT_LENGTH_QUERY, RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY, VerifiedPresignedRequest,
VerifiedSigV4Request, check_key_valid_with_context, get_condition_values_with_client_info,
get_condition_values_with_query_and_client_info, get_request_auth_type_with_query, get_session_token,
parse_presigned_multipart_max_total_object_size, parse_presigned_put_max_content_length,
AuthType, OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION, RUSTFS_MAX_CONTENT_LENGTH_QUERY,
RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY, VerifiedPresignedRequest, VerifiedSigV4Request, check_key_valid_with_context,
get_condition_values_with_client_info, get_condition_values_with_query_and_client_info, get_request_auth_type_with_query,
get_session_token, parse_presigned_multipart_max_total_object_size, parse_presigned_put_max_content_length,
reject_unsigned_amz_headers_on_sigv4_request,
};
use crate::error::ApiError;
@@ -61,6 +61,7 @@ use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
#[cfg(test)]
use std::sync::OnceLock;
use time::OffsetDateTime;
use url::{Url, form_urlencoded};
const EVENT_OBJECT_TAG_AUTHORIZATION: &str = "object_tag_authorization";
@@ -134,6 +135,11 @@ pub(crate) struct PostObjectRequestMarker;
#[derive(Clone, Debug)]
struct InternalObjectAuthorization;
/// Retain-until date the request asks to set. `authorization_conditions` turns it
/// into `object-lock-remaining-retention-days`.
#[derive(Clone, Debug)]
struct RequestedObjectLockRetainUntil(OffsetDateTime);
#[derive(Clone, Debug)]
struct StagedMultipartPartAuthorization;
@@ -879,9 +885,58 @@ fn authorization_conditions<T>(
}
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
merge_request_object_tag_conditions(action, &req.headers, &mut conditions)?;
merge_object_lock_remaining_retention_days_condition(
req.extensions.get::<RequestedObjectLockRetainUntil>(),
OffsetDateTime::now_utc(),
&mut conditions,
);
Ok(conditions)
}
/// Records the retain-until date a request asks to set, or clears it when the
/// request sets none. Call it before `authorize_request` with the parsed date from
/// headers, form fields, the PutObjectRetention XML body, or an archive member.
pub(crate) fn set_requested_object_lock_retain_until<T>(req: &mut S3Request<T>, retain_until: Option<&Timestamp>) {
match retain_until {
Some(retain_until) => {
req.extensions
.insert(RequestedObjectLockRetainUntil(OffsetDateTime::from(retain_until.clone())));
}
None => {
req.extensions.remove::<RequestedObjectLockRetainUntil>();
}
}
}
fn merge_object_lock_remaining_retention_days_condition(
requested: Option<&RequestedObjectLockRetainUntil>,
now: OffsetDateTime,
conditions: &mut HashMap<String, Vec<String>>,
) {
if let Some(RequestedObjectLockRetainUntil(retain_until)) = requested {
conditions.insert(
OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION.to_string(),
vec![remaining_retention_days(*retain_until, now).to_string()],
);
}
}
/// Whole days from `now` to `retain_until`, rounded up as MinIO does, so a policy
/// limit of N days rejects a date even one second past day N. A date that is not
/// in the future yields 0.
fn remaining_retention_days(retain_until: OffsetDateTime, now: OffsetDateTime) -> i64 {
let remaining = retain_until - now;
if remaining <= time::Duration::ZERO {
return 0;
}
let days = remaining.whole_days();
if remaining > time::Duration::days(days) {
days + 1
} else {
days
}
}
fn retain_internal_object_authorization_conditions(
conditions: &mut HashMap<String, Vec<String>>,
cred: &rustfs_credentials::Credentials,
@@ -2256,6 +2311,8 @@ impl S3Access for FS {
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
let requested_retain_until = req.input.object_lock_retain_until_date.clone();
set_requested_object_lock_retain_until(req, requested_retain_until.as_ref());
authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await?;
authorize_replication_only_put_headers(req).await?;
@@ -2280,6 +2337,8 @@ impl S3Access for FS {
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
let requested_retain_until = req.input.object_lock_retain_until_date.clone();
set_requested_object_lock_retain_until(req, requested_retain_until.as_ref());
authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await?;
authorize_replication_only_put_headers(req).await?;
@@ -3210,6 +3269,8 @@ impl S3Access for FS {
// Snapshot before authorization, but preserve AccessDenied precedence
// by exposing any bucket-state error only after authorization succeeds.
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let requested_retain_until = req.input.object_lock_retain_until_date.clone();
set_requested_object_lock_retain_until(req, requested_retain_until.as_ref());
authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await?;
req.extensions.insert(bucket_generation?);
@@ -3275,6 +3336,12 @@ impl S3Access for FS {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let requested_retain_until = req
.input
.retention
.as_ref()
.and_then(|retention| retention.retain_until_date.clone());
set_requested_object_lock_retain_until(req, requested_retain_until.as_ref());
authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?;
// S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission
@@ -3416,6 +3483,8 @@ mod tests {
table_data_plane_content_mutation, table_data_plane_resource_for_request, table_publication_guard_error,
validate_post_object_success_controls, versioned_read_action,
};
use super::{remaining_retention_days, set_requested_object_lock_retain_until};
use crate::auth::OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION;
use crate::error::ApiError;
use crate::storage::storage_api::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
use crate::storage::storage_api::contract::multipart::MultipartOperations as _;
@@ -4216,6 +4285,164 @@ mod tests {
);
}
fn remaining_retention_days_conditions(req: &S3Request<()>, action: Action) -> HashMap<String, Vec<String>> {
let credentials = rustfs_credentials::Credentials::default();
authorization_conditions(req, &credentials, None, None, None, None, action).expect("conditions should build")
}
fn request_retaining_for(retain_for: time::Duration) -> S3Request<()> {
let mut req = build_request((), Method::PUT);
let retain_until = OffsetDateTime::now_utc() + retain_for;
set_requested_object_lock_retain_until(&mut req, Some(&retain_until.into()));
req
}
/// AWS does not document how it rounds partial days. MinIO computes
/// `ceil(hours / 24)` in `enforceRetentionBypassForPut`
/// (cmd/bucket-object-lock.go), and RustFS keeps that rounding so a policy
/// moved from MinIO evaluates the same way.
#[test]
fn remaining_retention_days_matches_s3_ceil_rounding() {
let now = OffsetDateTime::now_utc();
let cases = [
(time::Duration::NANOSECOND, 1),
(time::Duration::HOUR, 1),
(time::Duration::DAY - time::Duration::SECOND, 1),
(time::Duration::DAY, 1),
(time::Duration::DAY + time::Duration::SECOND, 2),
(time::Duration::days(29) + time::Duration::hours(12), 30),
(time::Duration::days(30), 30),
(time::Duration::days(30) + time::Duration::SECOND, 31),
];
for (retain_for, expected) in cases {
let minio_days = (retain_for.as_seconds_f64() / 3600.0 / 24.0).ceil() as i64;
assert_eq!(minio_days, expected, "MinIO reference for {retain_for}");
assert_eq!(remaining_retention_days(now + retain_for, now), expected, "retain for {retain_for}");
}
}
#[test]
fn remaining_retention_days_is_zero_for_dates_not_in_future() {
let now = OffsetDateTime::now_utc();
assert_eq!(remaining_retention_days(now, now), 0);
assert_eq!(remaining_retention_days(now - time::Duration::NANOSECOND, now), 0);
assert_eq!(remaining_retention_days(now - time::Duration::days(3), now), 0);
}
#[test]
fn requested_retain_until_sets_remaining_retention_days_condition() {
let req = request_retaining_for(time::Duration::days(10) - time::Duration::HOUR);
let conditions = remaining_retention_days_conditions(&req, Action::S3Action(S3Action::PutObjectAction));
assert_eq!(
conditions.get(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION),
Some(&vec!["10".to_string()])
);
}
#[test]
fn request_without_retention_has_no_remaining_retention_days_condition() {
let action = Action::S3Action(S3Action::PutObjectAction);
let req = build_request((), Method::PUT);
assert_eq!(
remaining_retention_days_conditions(&req, action).get(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION),
None
);
let mut req = request_retaining_for(time::Duration::days(5));
set_requested_object_lock_retain_until(&mut req, None);
assert_eq!(
remaining_retention_days_conditions(&req, action).get(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION),
None
);
}
#[test]
fn remaining_retention_days_condition_ignores_client_header() {
let action = Action::S3Action(S3Action::PutObjectRetentionAction);
let mut req = build_request((), Method::PUT);
req.headers
.insert(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION, HeaderValue::from_static("1"));
assert_eq!(
remaining_retention_days_conditions(&req, action).get(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION),
None
);
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(400);
set_requested_object_lock_retain_until(&mut req, Some(&retain_until.into()));
assert_eq!(
remaining_retention_days_conditions(&req, action).get(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION),
Some(&vec!["400".to_string()])
);
}
#[tokio::test]
async fn bucket_policy_limits_retention_to_thirty_days() {
let policy: BucketPolicy = serde_json::from_str(
r#"{
"Version":"2012-10-17",
"Statement":[
{
"Effect":"Allow",
"Principal":{"AWS":"*"},
"Action":["s3:PutObjectRetention"],
"Resource":["arn:aws:s3:::bucket/*"]
},
{
"Effect":"Deny",
"Principal":{"AWS":"*"},
"Action":["s3:PutObjectRetention"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"NumericGreaterThan":{"s3:object-lock-remaining-retention-days":"30"}}
}
]
}"#,
)
.expect("bucket policy should parse");
let no_groups: Option<Vec<String>> = None;
let action = Action::S3Action(S3Action::PutObjectRetentionAction);
let spoofed_header = |mut req: S3Request<()>| {
req.headers
.insert(OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION, HeaderValue::from_static("1"));
req
};
let cases = [
("29 days", request_retaining_for(time::Duration::days(29)), true),
("30 days", request_retaining_for(time::Duration::days(30)), true),
("31 days", request_retaining_for(time::Duration::days(31)), false),
(
"30 days and a minute",
request_retaining_for(time::Duration::days(30) + time::Duration::MINUTE),
false,
),
("no retention", build_request((), Method::PUT), true),
(
"31 days with spoofed header",
spoofed_header(request_retaining_for(time::Duration::days(31))),
false,
),
];
for (name, req, allowed) in cases {
let conditions = remaining_retention_days_conditions(&req, action);
let args = BucketPolicyArgs {
bucket: "bucket",
action,
is_owner: false,
account: "",
groups: &no_groups,
conditions: &conditions,
object: "obj",
};
assert_eq!(policy.is_allowed(&args).await, allowed, "{name}");
}
}
#[test]
fn internal_object_authorization_ignores_unapplied_request_options() {
let mut req = build_request((), Method::POST);
+1
View File
@@ -131,6 +131,7 @@ pub(crate) mod access_consumer {
load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
prepare_list_buckets_iam_authorization, prepare_odm_read_generation, recursive_force_delete_has_authenticated_caller,
replication_request_authorized, req_info_mut, req_info_ref, retained_table_data_plane_publication_guards,
set_requested_object_lock_retain_until,
};
}