diff --git a/crates/policy/src/policy/function/key_name.rs b/crates/policy/src/policy/function/key_name.rs index eeb054866..9ead5ca31 100644 --- a/crates/policy/src/policy/function/key_name.rs +++ b/crates/policy/src/policy/function/key_name.rs @@ -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))] diff --git a/crates/policy/src/policy/function/number.rs b/crates/policy/src/policy/function/number.rs index 7666e4a86..cb3af78cd 100644 --- a/crates/policy/src/policy/function/number.rs +++ b/crates/policy/src/policy/function/number.rs @@ -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, 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> { diff --git a/rustfs/src/app/object/extract.rs b/rustfs/src/app/object/extract.rs index c6080f143..8e72c2ab6 100644 --- a/rustfs/src/app/object/extract.rs +++ b/rustfs/src/app/object/extract.rs @@ -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); diff --git a/rustfs/src/app/object/mod.rs b/rustfs/src/app/object/mod.rs index c182225bc..e6e450436 100644 --- a/rustfs/src/app/object/mod.rs +++ b/rustfs/src/app/object/mod.rs @@ -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; diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index 68e1e4ffa..ebed1db3d 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -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, }; } diff --git a/rustfs/src/auth.rs b/rustfs/src/auth.rs index aa25b9432..68e17da96 100644 --- a/rustfs/src/auth.rs +++ b/rustfs/src/auth.rs @@ -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( } 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::(), + 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(req: &mut S3Request, retain_until: Option<&Timestamp>) { + match retain_until { + Some(retain_until) => { + req.extensions + .insert(RequestedObjectLockRetainUntil(OffsetDateTime::from(retain_until.clone()))); + } + None => { + req.extensions.remove::(); + } + } +} + +fn merge_object_lock_remaining_retention_days_condition( + requested: Option<&RequestedObjectLockRetainUntil>, + now: OffsetDateTime, + conditions: &mut HashMap>, +) { + 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>, 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> { + 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> = 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); diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index 1632fbf92..91d76c57d 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -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, }; }