fix(authz): fail closed on policy load errors (#5359)

* fix(authz): fail closed on policy load errors

* test(authz): cover policy failure precedence

* test: align policy failure expectations

* test: align upload part copy fail-closed expectation
This commit is contained in:
cxymds
2026-07-28 17:04:35 +08:00
committed by GitHub
parent 2216f00cfd
commit 4fb9b0dc7f
5 changed files with 163 additions and 36 deletions
+18
View File
@@ -741,6 +741,8 @@ impl BucketMetadataSys {
if let Some(config) = &bm.policy_config {
Ok((config.clone(), bm.policy_config_updated_at))
} else if !bm.policy_config_json.is_empty() {
Ok((serde_json::from_slice(&bm.policy_config_json)?, bm.policy_config_updated_at))
} else {
Err(Error::ConfigNotFound)
}
@@ -1051,6 +1053,22 @@ mod tests {
);
}
#[tokio::test]
async fn get_bucket_policy_rejects_malformed_cached_policy() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
let mut metadata = BucketMetadata::new("malformed-policy");
metadata.policy_config_json = b"{".to_vec();
sys.set("malformed-policy".to_string(), Arc::new(metadata)).await;
let err = sys
.get_bucket_policy("malformed-policy")
.await
.expect_err("malformed persisted policy must not be treated as missing");
assert!(matches!(err, Error::Io(_)), "malformed persisted policy must surface its parse failure");
}
fn target(bucket: &str, id: &str) -> BucketTarget {
BucketTarget {
source_bucket: bucket.to_string(),
+102 -11
View File
@@ -15,23 +15,26 @@
use super::metadata_sys::get_bucket_metadata_sys;
use crate::error::{Result, StorageError};
use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs};
use tracing::info;
pub struct PolicySys {}
impl PolicySys {
pub async fn is_allowed(args: &BucketPolicyArgs<'_>) -> bool {
match Self::get(args.bucket).await {
Ok(cfg) => return cfg.is_allowed(args).await,
Err(err) => {
if err != StorageError::ConfigNotFound {
info!("config get err {:?}", err);
}
}
}
args.is_owner
matches!(Self::try_is_allowed(args).await, Ok(true))
}
pub async fn try_is_allowed(args: &BucketPolicyArgs<'_>) -> Result<bool> {
Self::is_allowed_with_policy(args, Self::get(args.bucket).await).await
}
async fn is_allowed_with_policy(args: &BucketPolicyArgs<'_>, policy: Result<BucketPolicy>) -> Result<bool> {
match policy {
Ok(policy) => Ok(policy.is_allowed(args).await),
Err(StorageError::ConfigNotFound) => Ok(args.is_owner),
Err(err) => Err(err),
}
}
pub async fn get(bucket: &str) -> Result<BucketPolicy> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -41,3 +44,91 @@ impl PolicySys {
Ok(cfg)
}
}
#[cfg(test)]
mod tests {
use super::{PolicySys, StorageError};
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs};
use std::collections::HashMap;
fn args<'a>(
is_owner: bool,
groups: &'a Option<Vec<String>>,
conditions: &'a HashMap<String, Vec<String>>,
) -> BucketPolicyArgs<'a> {
BucketPolicyArgs {
bucket: "bucket",
action: Action::S3Action(S3Action::GetObjectAction),
is_owner,
account: "account",
groups,
conditions,
object: "object",
}
}
#[tokio::test]
async fn missing_policy_preserves_owner_and_iam_fallback_semantics() {
let groups = None;
let conditions = HashMap::new();
assert!(
PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Err(StorageError::ConfigNotFound),)
.await
.expect("missing policy should preserve owner access")
);
assert!(
!PolicySys::is_allowed_with_policy(&args(false, &groups, &conditions), Err(StorageError::ConfigNotFound),)
.await
.expect("missing policy should defer non-owner access to IAM")
);
}
#[tokio::test]
async fn policy_load_failures_propagate() {
let groups = None;
let conditions = HashMap::new();
for (failure, expected_message) in [
(StorageError::Io(std::io::Error::other("policy read failed")), "policy read failed"),
(
StorageError::other("bucket metadata sys not initialized for this instance"),
"bucket metadata sys not initialized for this instance",
),
] {
let result = PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Err(failure)).await;
assert!(
matches!(result, Err(StorageError::Io(ref err)) if err.to_string().contains(expected_message)),
"policy I/O and uninitialized metadata failures must propagate instead of granting owner access"
);
}
}
#[tokio::test]
async fn explicit_bucket_deny_precedes_iam_allow() {
let groups = None;
let conditions = HashMap::new();
let policy: BucketPolicy = serde_json::from_str(
r#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Deny",
"Principal":{"AWS":"*"},
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"]
}]
}"#,
)
.expect("deny policy should parse");
let bucket_allowed = PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Ok(policy))
.await
.expect("loaded bucket policy should evaluate");
let iam_allowed = true;
let request_allowed = bucket_allowed && iam_allowed;
assert!(iam_allowed, "test precondition: IAM grants the action");
assert!(!bucket_allowed, "test precondition: bucket policy explicitly denies the action");
assert!(!request_allowed, "explicit bucket Deny must reject before IAM Allow fallback");
}
}
+6 -4
View File
@@ -1810,7 +1810,7 @@ impl DefaultBucketUsecase {
client_info,
);
let read_allowed = PolicySys::is_allowed(&BucketPolicyArgs {
let read_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: &bucket,
action: Action::S3Action(S3Action::ListBucketAction),
is_owner: false,
@@ -1819,9 +1819,10 @@ impl DefaultBucketUsecase {
conditions: &conditions,
object: "",
})
.await;
.await
.map_err(ApiError::from)?;
let write_allowed = PolicySys::is_allowed(&BucketPolicyArgs {
let write_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: &bucket,
action: Action::S3Action(S3Action::PutObjectAction),
is_owner: false,
@@ -1830,7 +1831,8 @@ impl DefaultBucketUsecase {
conditions: &conditions,
object: "",
})
.await;
.await
.map_err(ApiError::from)?;
let mut is_public = read_allowed || write_allowed;
let ignore_public_acls = match metadata_sys::get_public_access_block_config(&bucket).await {
+8
View File
@@ -494,6 +494,14 @@ mod tests {
assert!(downcast_storage_error.is_some());
}
#[test]
fn policy_metadata_read_failure_maps_to_internal_error() {
let api_error = ApiError::from(StorageError::Io(IoError::other("policy read failed")));
assert_eq!(api_error.code, S3ErrorCode::InternalError);
assert!(api_error.source.is_some());
}
#[test]
fn test_kms_service_unavailable_maps_to_retryable_error() {
let api_error = ApiError::from(StorageError::other(KmsUnavailableError));
+29 -21
View File
@@ -505,7 +505,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
let owner_can_bypass_deny = owner_can_bypass_policy_deny(is_owner, &action);
if !bucket_name.is_empty()
&& !owner_can_bypass_deny
&& !PolicySys::is_allowed(&BucketPolicyArgs {
&& !PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket_name,
action,
// Early explicit-deny gate for bucket policy: use owner short-circuit path so
@@ -517,6 +517,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
object: object.as_str(),
})
.await
.map_err(ApiError::from)?
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
@@ -535,7 +536,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
};
let delete_version_allowed = iam_store.eval_prepared(&prepared, &delete_version_args).await;
if !delete_version_allowed
&& !PolicySys::is_allowed(&BucketPolicyArgs {
&& !PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket.as_str(),
action: Action::S3Action(S3Action::DeleteObjectVersionAction),
is_owner,
@@ -545,6 +546,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
object: object.as_str(),
})
.await
.map_err(ApiError::from)?
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
@@ -571,7 +573,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
return Ok(());
}
let policy_allowed_fallback = PolicySys::is_allowed(&BucketPolicyArgs {
let policy_allowed_fallback = PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket.as_str(),
action,
is_owner,
@@ -580,7 +582,8 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
conditions: &conditions,
object: object.as_str(),
})
.await;
.await
.map_err(ApiError::from)?;
if policy_allowed_fallback {
authorize_table_data_plane_if_needed(action, bucket.as_str(), object.as_str(), cred, is_owner, &conditions, claims)
@@ -605,7 +608,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
return Ok(());
}
if PolicySys::is_allowed(&BucketPolicyArgs {
if PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket.as_str(),
action: Action::S3Action(S3Action::ListBucketAction),
is_owner,
@@ -615,6 +618,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
object: object.as_str(),
})
.await
.map_err(ApiError::from)?
{
return Ok(());
}
@@ -688,7 +692,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
let bucket_name = bucket.as_str();
if !bucket_name.is_empty()
&& !PolicySys::is_allowed(&BucketPolicyArgs {
&& !PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket_name,
action,
// Early explicit-deny gate for bucket policy in anonymous path.
@@ -699,13 +703,14 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
object: object.as_str(),
})
.await
.map_err(ApiError::from)?
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
if action != Action::S3Action(S3Action::ListAllMyBucketsAction) {
if action == Action::S3Action(S3Action::DeleteObjectAction) && version_id.is_some() {
let delete_version_allowed = PolicySys::is_allowed(&BucketPolicyArgs {
let delete_version_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket.as_str(),
action: Action::S3Action(S3Action::DeleteObjectVersionAction),
is_owner: false,
@@ -714,13 +719,14 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
conditions: &conditions,
object: object.as_str(),
})
.await;
.await
.map_err(ApiError::from)?;
if !delete_version_allowed {
return Err(s3_error!(AccessDenied, "Access Denied"));
}
}
let policy_allowed = PolicySys::is_allowed(&BucketPolicyArgs {
let policy_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket.as_str(),
action,
is_owner: false,
@@ -729,7 +735,8 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
conditions: &conditions,
object: object.as_str(),
})
.await;
.await
.map_err(ApiError::from)?;
// A bucket policy granting s3:ListBucket also covers listing versions. This
// fallback has to feed the same post-authorization gates as the direct grant
@@ -737,7 +744,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
// ListObjectVersions after RestrictPublicBuckets is turned on.
let policy_allowed = policy_allowed
|| (action == Action::S3Action(S3Action::ListBucketVersionsAction)
&& PolicySys::is_allowed(&BucketPolicyArgs {
&& PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: bucket.as_str(),
action: Action::S3Action(S3Action::ListBucketAction),
is_owner: false,
@@ -746,7 +753,8 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
conditions: &conditions,
object: "",
})
.await);
.await
.map_err(ApiError::from)?);
if policy_allowed {
deny_anonymous_table_data_plane_if_needed(action, bucket.as_str(), object.as_str()).await?;
@@ -2763,7 +2771,7 @@ mod tests {
}
#[tokio::test]
async fn abort_multipart_upload_rejects_unauthorized_request() {
async fn abort_multipart_upload_fails_closed_when_policy_metadata_is_unavailable() {
let fs = FS::new();
let mut req = build_request(
AbortMultipartUploadInput::builder()
@@ -2779,12 +2787,12 @@ mod tests {
let err = fs
.abort_multipart_upload(&mut req)
.await
.expect_err("missing credentials should reject access");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
.expect_err("unavailable policy metadata must fail closed");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn complete_multipart_upload_rejects_unauthorized_request() {
async fn complete_multipart_upload_fails_closed_when_policy_metadata_is_unavailable() {
let fs = FS::new();
let mut req = build_request(
CompleteMultipartUploadInput::builder()
@@ -2801,12 +2809,12 @@ mod tests {
let err = fs
.complete_multipart_upload(&mut req)
.await
.expect_err("missing credentials should reject access");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
.expect_err("unavailable policy metadata must fail closed");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn upload_part_copy_rejects_unauthorized_request() {
async fn upload_part_copy_fails_closed_when_policy_metadata_is_unavailable() {
let fs = FS::new();
let mut req = build_request(
UploadPartCopyInput::builder()
@@ -2828,7 +2836,7 @@ mod tests {
let err = fs
.upload_part_copy(&mut req)
.await
.expect_err("missing credentials should reject access");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
.expect_err("unavailable policy metadata must fail closed");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}