diff --git a/.config/nextest.toml b/.config/nextest.toml index 584c1b216..5e50b639a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -222,7 +222,7 @@ test-group = 'ecstore-serial-flaky' [profile.e2e-smoke] default-filter = """ package(e2e_test) & ( - test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/) + test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/) | test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/) | test(/^reliant::lifecycle::/) | test(/^reliant::tiering::/) diff --git a/crates/e2e_test/src/common.rs b/crates/e2e_test/src/common.rs index 7a6196bca..a5b5476f1 100644 --- a/crates/e2e_test/src/common.rs +++ b/crates/e2e_test/src/common.rs @@ -65,8 +65,14 @@ fn configured_capture_log_path(temp_dir: &str) -> Option { capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned()) } -fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str, provider_name: &'static str) -> Config { - let credentials = Credentials::new(access_key, secret_key, None, None, provider_name); +pub(crate) fn build_test_s3_config( + endpoint_url: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + provider_name: &'static str, +) -> Config { + let credentials = Credentials::new(access_key, secret_key, session_token.map(str::to_owned), None, provider_name); let mut config = Config::builder() .credentials_provider(credentials) .region(Region::new("us-east-1")) @@ -81,6 +87,33 @@ fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str, config.build() } +pub(crate) fn build_test_sts_client( + endpoint_url: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + provider_name: &'static str, +) -> aws_sdk_sts::Client { + let mut config = aws_sdk_sts::Config::builder() + .credentials_provider(aws_sdk_sts::config::Credentials::new( + access_key, + secret_key, + session_token.map(str::to_owned), + None, + provider_name, + )) + .region(aws_sdk_sts::config::Region::new("us-east-1")) + .endpoint_url(endpoint_url) + .retry_config(aws_sdk_sts::config::retry::RetryConfig::standard().with_max_attempts(1)) + .behavior_version_latest(); + + if endpoint_url.starts_with("http://") { + config = config.http_client(SmithyHttpClientBuilder::new().build_http()); + } + + aws_sdk_sts::Client::from_conf(config.build()) +} + pub fn workspace_root() -> PathBuf { let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); path.pop(); // e2e_test @@ -569,7 +602,7 @@ impl RustFSTestEnvironment { /// Create an AWS S3 client with explicit credentials for this RustFS instance. pub fn create_s3_client_with_credentials(&self, access_key: &str, secret_key: &str) -> Client { - Client::from_conf(build_test_s3_config(&self.url, access_key, secret_key, "e2e-test")) + Client::from_conf(build_test_s3_config(&self.url, access_key, secret_key, None, "e2e-test")) } /// Create test bucket @@ -1301,6 +1334,7 @@ impl RustFSTestClusterEnvironment { &self.nodes[node_idx].url, &self.access_key, &self.secret_key, + None, "cluster-test", ))) } diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index a6580debd..2c2b985c8 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -294,6 +294,10 @@ mod list_buckets_double_slash_test; #[cfg(test)] mod list_buckets_auth_test; +// ListBuckets visibility follows IAM authorization, not bucket policy. +#[cfg(test)] +mod list_buckets_iam_filter_test; + // Regression test for backlog#629(b): region-aware CreateBucket SigV4. #[cfg(test)] mod create_bucket_region_test; diff --git a/crates/e2e_test/src/list_buckets_iam_filter_test.rs b/crates/e2e_test/src/list_buckets_iam_filter_test.rs new file mode 100644 index 000000000..f82f30e9a --- /dev/null +++ b/crates/e2e_test/src/list_buckets_iam_filter_test.rs @@ -0,0 +1,441 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::common::{RustFSTestEnvironment, admin_ok, build_test_s3_config, build_test_sts_client, init_logging}; +use aws_sdk_s3::Client; +use aws_sdk_s3::error::ProvideErrorMetadata; +use serial_test::serial; + +fn user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client { + Client::from_conf(build_test_s3_config( + &env.url, + access_key, + secret_key, + session_token, + "list-buckets-iam-filter", + )) +} + +fn bucket_names(buckets: &[aws_sdk_s3::types::Bucket]) -> Vec { + let mut names = buckets + .iter() + .filter_map(|bucket| bucket.name().map(str::to_owned)) + .collect::>(); + names.sort(); + names +} + +async fn create_user( + env: &RustFSTestEnvironment, + access_key: &str, + secret_key: &str, +) -> Result<(), Box> { + let body = serde_json::json!({ "secretKey": secret_key, "status": "enabled" }).to_string(); + admin_ok( + env, + http::Method::PUT, + &format!("/rustfs/admin/v3/add-user?accessKey={access_key}"), + Some(body), + ) + .await?; + Ok(()) +} + +async fn create_service_account( + env: &RustFSTestEnvironment, + target_user: &str, + policy: Option<&serde_json::Value>, +) -> Result<(String, String), Box> { + let request = match policy { + Some(policy) => serde_json::json!({ "targetUser": target_user, "policy": policy }), + None => serde_json::json!({ "targetUser": target_user }), + }; + let response = admin_ok(env, http::Method::PUT, "/rustfs/admin/v3/add-service-accounts", Some(request.to_string())).await?; + let response: serde_json::Value = serde_json::from_str(&response)?; + let access_key = response["credentials"]["accessKey"] + .as_str() + .ok_or("service account response should contain credentials.accessKey")? + .to_owned(); + let secret_key = response["credentials"]["secretKey"] + .as_str() + .ok_or("service account response should contain credentials.secretKey")? + .to_owned(); + Ok((access_key, secret_key)) +} + +#[tokio::test] +#[serial] +async fn list_buckets_filters_with_iam_bucket_resources() -> Result<(), Box> { + init_logging(); + let mut env = RustFSTestEnvironment::new().await?; + env.capture_log_path = Some(format!("{}/server.log", env.temp_dir)); + env.start_rustfs_server_with_env(vec![], &[("RUST_LOG", "rustfs=debug,rustfs_notify=debug")]) + .await?; + + let admin_client = env.create_s3_client(); + for bucket in [ + "benchmark-artifacts", + "benchmark-denied", + "benchmark-location-only", + "benchmark-test1", + "testuser1-artifacts", + ] { + admin_client.create_bucket().bucket(bucket).send().await?; + } + assert_eq!( + bucket_names(admin_client.list_buckets().send().await?.buckets()), + vec![ + "benchmark-artifacts", + "benchmark-denied", + "benchmark-location-only", + "benchmark-test1", + "testuser1-artifacts" + ] + ); + + let access_key = "benchmark"; + let secret_key = "benchmark-secret-1234567890"; + create_user(&env, access_key, secret_key).await?; + + let policy_name = "benchmark-bucket-prefix"; + let policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": ["arn:aws:s3:::benchmark-*", "arn:aws:s3:::benchmark-*/*"], + "Condition": { + "StringEquals": { + "s3:prefix": [""], + "s3:delimiter": ["/"] + } + } + }, + { + "Effect": "Deny", + "Action": ["s3:ListBucket", "s3:GetBucketLocation"], + "Resource": ["arn:aws:s3:::benchmark-denied"] + }, + { + "Effect": "Deny", + "Action": ["s3:ListBucket"], + "Resource": ["arn:aws:s3:::benchmark-location-only"] + }, + { + "Effect": "Allow", + "Action": ["sts:AssumeRole"], + "Resource": ["arn:aws:s3:::*"] + } + ] + }) + .to_string(); + admin_ok( + &env, + http::Method::PUT, + &format!("/rustfs/admin/v3/add-canned-policy?name={policy_name}"), + Some(policy), + ) + .await?; + admin_ok( + &env, + http::Method::PUT, + &format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={access_key}&isGroup=false"), + Some(String::new()), + ) + .await?; + + let bucket_policy_allow = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { "AWS": [access_key] }, + "Action": ["s3:ListBucket"], + "Resource": ["arn:aws:s3:::testuser1-artifacts"] + }] + }) + .to_string(); + admin_client + .put_bucket_policy() + .bucket("testuser1-artifacts") + .policy(bucket_policy_allow) + .send() + .await?; + + let bucket_policy_deny = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Principal": { "AWS": [access_key] }, + "Action": ["s3:ListBucket", "s3:GetBucketLocation"], + "Resource": ["arn:aws:s3:::benchmark-artifacts"] + }] + }) + .to_string(); + admin_client + .put_bucket_policy() + .bucket("benchmark-artifacts") + .policy(bucket_policy_deny) + .send() + .await?; + + let benchmark_client = user_client(&env, access_key, secret_key, None); + benchmark_client + .list_objects_v2() + .bucket("testuser1-artifacts") + .send() + .await?; + + assert_eq!( + bucket_names(benchmark_client.list_buckets().send().await?.buckets()), + vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"] + ); + let audit_log = + tokio::fs::read_to_string(env.capture_log_path.as_deref().expect("server log path should be configured")).await?; + assert_eq!(audit_log.matches("iam_implicit_deny").count(), 1, "{audit_log}"); + for field in ["s3_authorization_denied", "ListAllMyBucketsAction", "benchmark", "DEBUG"] { + assert!(audit_log.contains(field), "missing {field} in {audit_log}"); + } + + let denied_access_key = "no-bucket-access"; + let denied_secret_key = "no-bucket-access-secret-1234567890"; + create_user(&env, denied_access_key, denied_secret_key).await?; + let denied = user_client(&env, denied_access_key, denied_secret_key, None) + .list_buckets() + .send() + .await + .expect_err("a user without IAM bucket permissions must be denied"); + assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied")); + + let put_only_policy_name = "put-only-no-bucket-discovery"; + let put_only_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:PutObject"], + "Resource": ["arn:aws:s3:::benchmark-*/*"] + }] + }) + .to_string(); + admin_ok( + &env, + http::Method::PUT, + &format!("/rustfs/admin/v3/add-canned-policy?name={put_only_policy_name}"), + Some(put_only_policy), + ) + .await?; + admin_ok( + &env, + http::Method::PUT, + &format!( + "/rustfs/admin/v3/set-user-or-group-policy?policyName={put_only_policy_name}&userOrGroup={denied_access_key}&isGroup=false" + ), + Some(String::new()), + ) + .await?; + let denied = user_client(&env, denied_access_key, denied_secret_key, None) + .list_buckets() + .send() + .await + .expect_err("an unrelated IAM action must not reveal bucket names"); + assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied")); + + let list_all_policy_name = "list-all-buckets"; + let list_all_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:ListAllMyBuckets"], + "Resource": ["arn:aws:s3:::*"] + }] + }) + .to_string(); + admin_ok( + &env, + http::Method::PUT, + &format!("/rustfs/admin/v3/add-canned-policy?name={list_all_policy_name}"), + Some(list_all_policy), + ) + .await?; + admin_ok( + &env, + http::Method::PUT, + &format!( + "/rustfs/admin/v3/set-user-or-group-policy?policyName={list_all_policy_name}&userOrGroup={denied_access_key}&isGroup=false" + ), + Some(String::new()), + ) + .await?; + assert_eq!( + bucket_names( + user_client(&env, denied_access_key, denied_secret_key, None) + .list_buckets() + .send() + .await? + .buckets() + ), + vec![ + "benchmark-artifacts", + "benchmark-denied", + "benchmark-location-only", + "benchmark-test1", + "testuser1-artifacts" + ] + ); + + let group_user = "benchmark-group-user"; + let group_secret = "benchmark-group-secret-1234567890"; + let group_name = "benchmark-group"; + create_user(&env, group_user, group_secret).await?; + admin_ok( + &env, + http::Method::PUT, + "/rustfs/admin/v3/update-group-members", + Some( + serde_json::json!({ + "group": group_name, + "members": [group_user], + "isRemove": false, + "groupStatus": "enabled" + }) + .to_string(), + ), + ) + .await?; + admin_ok( + &env, + http::Method::PUT, + &format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={group_name}&isGroup=true"), + Some(String::new()), + ) + .await?; + + assert_eq!( + bucket_names( + user_client(&env, group_user, group_secret, None) + .list_buckets() + .send() + .await? + .buckets() + ), + vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"] + ); + + let (service_access_key, service_secret_key) = create_service_account(&env, group_user, None).await?; + assert_eq!( + bucket_names( + user_client(&env, &service_access_key, &service_secret_key, None) + .list_buckets() + .send() + .await? + .buckets() + ), + vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"] + ); + + let service_account_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:ListBucket", "s3:GetBucketLocation"], + "Resource": ["arn:aws:s3:::benchmark-test1"], + "Condition": { + "StringEquals": { + "s3:prefix": [""], + "s3:delimiter": ["/"] + } + } + }] + }); + let (restricted_service_access_key, restricted_service_secret_key) = + create_service_account(&env, group_user, Some(&service_account_policy)).await?; + assert_eq!( + bucket_names( + user_client(&env, &restricted_service_access_key, &restricted_service_secret_key, None,) + .list_buckets() + .send() + .await? + .buckets() + ), + vec!["benchmark-test1"] + ); + + let sts_client = build_test_sts_client(&env.url, group_user, group_secret, None, "list-buckets-iam-filter-sts"); + let inherited = sts_client + .assume_role() + .role_arn("arn:aws:iam::123456789012:role/list-buckets") + .role_session_name("list-buckets-iam-filter-inherited") + .send() + .await?; + let inherited = inherited + .credentials() + .ok_or("AssumeRole response should contain inherited temporary credentials")?; + assert_eq!( + bucket_names( + user_client( + &env, + inherited.access_key_id(), + inherited.secret_access_key(), + Some(inherited.session_token()), + ) + .list_buckets() + .send() + .await? + .buckets() + ), + vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"] + ); + + let session_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:ListBucket", "s3:GetBucketLocation"], + "Resource": ["arn:aws:s3:::benchmark-test1"], + "Condition": { + "StringEquals": { + "s3:prefix": [""], + "s3:delimiter": ["/"] + } + } + }] + }) + .to_string(); + let assumed = sts_client + .assume_role() + .role_arn("arn:aws:iam::123456789012:role/list-buckets") + .role_session_name("list-buckets-iam-filter") + .policy(session_policy) + .send() + .await?; + let temporary = assumed + .credentials() + .ok_or("AssumeRole response should contain temporary credentials")?; + assert_eq!( + bucket_names( + user_client( + &env, + temporary.access_key_id(), + temporary.secret_access_key(), + Some(temporary.session_token()), + ) + .list_buckets() + .send() + .await? + .buckets() + ), + vec!["benchmark-test1"] + ); + Ok(()) +} diff --git a/crates/e2e_test/src/sts_query_compat_test.rs b/crates/e2e_test/src/sts_query_compat_test.rs index 36484a050..517cc1c82 100644 --- a/crates/e2e_test/src/sts_query_compat_test.rs +++ b/crates/e2e_test/src/sts_query_compat_test.rs @@ -12,13 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::common::{RustFSTestEnvironment, admin_ok, init_logging}; -use aws_sdk_sts::config::retry::RetryConfig; -use aws_sdk_sts::config::{Credentials, Region}; +use crate::common::{RustFSTestEnvironment, admin_ok, build_test_s3_config, build_test_sts_client, init_logging}; +use aws_sdk_sts::Client; use aws_sdk_sts::error::ProvideErrorMetadata; use aws_sdk_sts::operation::RequestId; -use aws_sdk_sts::{Client, Config}; -use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; use bytes::Bytes; use http::header::{AUTHORIZATION, CONTENT_TYPE}; use http::{Request, Response}; @@ -32,9 +29,8 @@ use serial_test::serial; use std::collections::BTreeSet; use std::convert::Infallible; use std::error::Error; -use std::sync::Arc; use tokio::net::TcpListener; -use tokio::sync::{Notify, mpsc}; +use tokio::sync::mpsc; use tokio::task::{JoinHandle, JoinSet}; use tokio::time::{Duration, timeout}; @@ -43,22 +39,7 @@ type TestResult = Result<(), BoxError>; const OPA_AUTH_TOKEN: &str = "sts-opa-token"; fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client { - let mut config = Config::builder() - .credentials_provider(Credentials::new( - access_key, - secret_key, - session_token.map(str::to_owned), - None, - "e2e-sts-query-compat", - )) - .region(Region::new("us-east-1")) - .endpoint_url(url) - .retry_config(RetryConfig::standard().with_max_attempts(1)) - .behavior_version_latest(); - if url.starts_with("http://") { - config = config.http_client(SmithyHttpClientBuilder::new().build_http()); - } - Client::from_conf(config.build()) + build_test_sts_client(url, access_key, secret_key, session_token, "e2e-sts-query-compat") } async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(String, String), BoxError> { @@ -145,6 +126,52 @@ async fn assert_access_denied(client: &Client, context: &str) -> TestResult { Ok(()) } +async fn assert_list_buckets_access_denied( + env: &RustFSTestEnvironment, + access_key: &str, + secret_key: &str, + context: &str, +) -> TestResult { + let error = aws_sdk_s3::Client::from_conf(build_test_s3_config( + &env.url, + access_key, + secret_key, + None, + "e2e-list-buckets-opa-unavailable", + )) + .list_buckets() + .send() + .await + .expect_err("ListBuckets must be denied while OPA is unavailable"); + let service_error = error + .as_service_error() + .ok_or_else(|| format!("{context} should deserialize as an S3 service error: {error:?}"))?; + + assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(403)); + assert_eq!(service_error.code(), Some("AccessDenied")); + Ok(()) +} + +async fn assert_opa_unavailable_denies_sts_and_list_buckets(env: &RustFSTestEnvironment, context: &str) -> TestResult { + let user = "opaunavailable"; + let secret = "stsOpaUnavailableSecret123"; + create_user_with_policy( + env, + user, + secret, + "sts-opa-unavailable-local-policy", + serde_json::json!([{ + "Effect": "Allow", + "Action": ["s3:ListAllMyBuckets"], + "Resource": ["arn:aws:s3:::*"], + }]), + ) + .await?; + + assert_access_denied(&sts_client(&env.url, user, secret, None), context).await?; + assert_list_buckets_access_denied(env, user, secret, context).await +} + async fn handle_opa_request( request: Request, requests: mpsc::UnboundedSender, @@ -186,12 +213,15 @@ async fn handle_opa_request( }; if payload.is_none() { let _ = validation_started.send(()); - if let OpaValidationMode::DelayedUnavailable(release) = validation_mode { - release.notified().await; - return Ok(Response::builder() - .status(503) - .body(Full::new(Bytes::new())) - .expect("static OPA unavailable response must be valid")); + match validation_mode { + OpaValidationMode::Blocked => std::future::pending::<()>().await, + OpaValidationMode::Unavailable => { + return Ok(Response::builder() + .status(503) + .body(Full::new(Bytes::new())) + .expect("static OPA unavailable response must be valid")); + } + OpaValidationMode::Ready => {} } } let allow = match payload.as_ref().and_then(|value| value.pointer("/input/identity/account")) { @@ -201,6 +231,25 @@ async fn handle_opa_request( .and_then(Value::as_bool) .unwrap_or(false), Some(Value::String(account)) if account == "opadeny" => false, + Some(Value::String(account)) + if account == "opaunavailable" && matches!(validation_mode, OpaValidationMode::Unavailable) => + { + true + } + Some(Value::String(account)) if account == "opalistbuckets" => { + let action = payload + .as_ref() + .and_then(|value| value.pointer("/input/action")) + .and_then(Value::as_str); + let bucket = payload + .as_ref() + .and_then(|value| value.pointer("/input/resource/bucket")) + .and_then(Value::as_str); + matches!( + (action, bucket), + (Some("s3:ListBucket"), Some("opa-list-visible")) | (Some("s3:GetBucketLocation"), Some("opa-list-location")) + ) + } None => true, _ => false, }; @@ -215,17 +264,17 @@ async fn handle_opa_request( .expect("static OPA response must be valid")) } -#[derive(Clone)] +#[derive(Clone, Copy)] enum OpaValidationMode { Ready, - DelayedUnavailable(Arc), + Blocked, + Unavailable, } struct OpaMock { url: String, requests: mpsc::UnboundedReceiver, validation_started: mpsc::UnboundedReceiver<()>, - validation_release: Option>, task: JoinHandle<()>, } @@ -234,9 +283,8 @@ impl OpaMock { Self::start_with_mode(OpaValidationMode::Ready, Some(OPA_AUTH_TOKEN)).await } - async fn start_delayed_unavailable() -> Result { - let release = Arc::new(Notify::new()); - Self::start_with_mode(OpaValidationMode::DelayedUnavailable(release), None).await + async fn start_blocked() -> Result { + Self::start_with_mode(OpaValidationMode::Blocked, None).await } async fn start_with_mode(validation_mode: OpaValidationMode, auth_token: Option<&str>) -> Result { @@ -245,10 +293,6 @@ impl OpaMock { let (requests_tx, requests) = mpsc::unbounded_channel(); let (validation_started_tx, validation_started) = mpsc::unbounded_channel(); let expected_authorization = auth_token.map(|token| format!("Bearer {token}")); - let validation_release = match &validation_mode { - OpaValidationMode::Ready => None, - OpaValidationMode::DelayedUnavailable(release) => Some(Arc::clone(release)), - }; let task = tokio::spawn(async move { let mut connections = JoinSet::new(); loop { @@ -257,7 +301,7 @@ impl OpaMock { let Ok((stream, _)) = accepted else { break }; let requests = requests_tx.clone(); let validation_started = validation_started_tx.clone(); - let validation_mode = validation_mode.clone(); + let validation_mode = validation_mode; let expected_authorization = expected_authorization.clone(); connections.spawn(async move { let handler = service_fn(move |request| { @@ -265,7 +309,7 @@ impl OpaMock { request, requests.clone(), validation_started.clone(), - validation_mode.clone(), + validation_mode, expected_authorization.clone(), ) }); @@ -282,7 +326,6 @@ impl OpaMock { url, requests, validation_started, - validation_release, task, }) } @@ -298,12 +341,6 @@ impl OpaMock { .await? .ok_or_else(|| "OPA validation channel closed".into()) } - - fn release_validation(&self) { - if let Some(release) = &self.validation_release { - release.notify_one(); - } - } } impl Drop for OpaMock { @@ -523,35 +560,119 @@ async fn test_sts_assume_role_opa_contract() -> TestResult { #[tokio::test] #[serial] -async fn test_sts_assume_role_fails_closed_while_opa_is_unavailable() -> TestResult { +async fn test_list_buckets_opa_contract() -> TestResult { init_logging(); - let mut opa = OpaMock::start_delayed_unavailable().await?; + let mut opa = OpaMock::start().await?; + let mut env = RustFSTestEnvironment::new().await?; + env.start_rustfs_server_with_env( + vec![], + &[ + ("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()), + ("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN), + ], + ) + .await?; + + let admin_client = env.create_s3_client(); + for bucket in ["opa-list-hidden", "opa-list-location", "opa-list-visible"] { + admin_client.create_bucket().bucket(bucket).send().await?; + } + + let user = "opalistbuckets"; + let secret = "opaListBucketsSecret123"; + create_user(&env, user, secret).await?; + + let output = aws_sdk_s3::Client::from_conf(build_test_s3_config(&env.url, user, secret, None, "e2e-list-buckets-opa")) + .list_buckets() + .send() + .await?; + let mut names = output + .buckets() + .iter() + .filter_map(|bucket| bucket.name().map(str::to_owned)) + .collect::>(); + names.sort(); + assert_eq!(names, ["opa-list-location", "opa-list-visible"]); + + let mut evaluations = BTreeSet::new(); + for _ in 0..6 { + let request = opa.next_request().await?; + assert_eq!(request.pointer("/input/identity/account").and_then(Value::as_str), Some(user)); + assert_eq!(request.pointer("/input/context/deny_only").and_then(Value::as_bool), Some(false)); + + let action = request + .pointer("/input/action") + .and_then(Value::as_str) + .ok_or("OPA ListBuckets input should include action")?; + let bucket = request + .pointer("/input/resource/bucket") + .and_then(Value::as_str) + .ok_or("OPA ListBuckets input should include resource.bucket")?; + if bucket.is_empty() { + assert_eq!(action, "s3:ListAllMyBuckets"); + assert!(request.pointer("/input/context/conditions/prefix").is_none()); + assert!(request.pointer("/input/context/conditions/delimiter").is_none()); + } else { + let expected_arn = format!("arn:aws:s3:::{bucket}"); + assert_eq!(request.pointer("/input/context/conditions/prefix"), Some(&serde_json::json!([""]))); + assert_eq!(request.pointer("/input/context/conditions/delimiter"), Some(&serde_json::json!(["/"]))); + assert_eq!( + request.pointer("/input/resource/arn").and_then(Value::as_str), + Some(expected_arn.as_str()) + ); + } + evaluations.insert((action.to_owned(), bucket.to_owned())); + } + assert_eq!( + evaluations, + BTreeSet::from([ + ("s3:GetBucketLocation".to_owned(), "opa-list-hidden".to_owned()), + ("s3:GetBucketLocation".to_owned(), "opa-list-location".to_owned()), + ("s3:ListAllMyBuckets".to_owned(), String::new()), + ("s3:ListBucket".to_owned(), "opa-list-hidden".to_owned()), + ("s3:ListBucket".to_owned(), "opa-list-location".to_owned()), + ("s3:ListBucket".to_owned(), "opa-list-visible".to_owned()), + ]) + ); + assert!( + matches!(opa.requests.try_recv(), Err(mpsc::error::TryRecvError::Empty)), + "ListBuckets should not make redundant OPA evaluations" + ); + + env.stop_server(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn test_sts_and_list_buckets_fail_closed_while_opa_is_initializing() -> TestResult { + init_logging(); + + let mut opa = OpaMock::start_blocked().await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server_with_env(vec![], &[("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str())]) .await?; opa.wait_for_validation().await?; - let user = "opaunavailable"; - let secret = "stsOpaUnavailableSecret123"; - create_user_with_policy( - &env, - user, - secret, - "sts-opa-unavailable-local-policy", - serde_json::json!([{ - "Effect": "Allow", - "Action": ["s3:ListAllMyBuckets"], - "Resource": ["arn:aws:s3:::*"], - }]), - ) - .await?; + assert_opa_unavailable_denies_sts_and_list_buckets(&env, "configured OPA initialization").await?; - assert_access_denied(&sts_client(&env.url, user, secret, None), "configured OPA initialization").await?; + env.stop_server(); + Ok(()) +} - opa.release_validation(); - tokio::time::sleep(Duration::from_millis(200)).await; - assert_access_denied(&sts_client(&env.url, user, secret, None), "configured OPA validation failure").await?; +#[tokio::test] +#[serial] +async fn test_sts_and_list_buckets_fail_closed_after_opa_validation_failure() -> TestResult { + init_logging(); + + let mut opa = OpaMock::start_with_mode(OpaValidationMode::Unavailable, None).await?; + let mut env = RustFSTestEnvironment::new().await?; + env.start_rustfs_server_with_env(vec![], &[("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str())]) + .await?; + opa.wait_for_validation().await?; + + assert_opa_unavailable_denies_sts_and_list_buckets(&env, "configured OPA validation failure").await?; env.stop_server(); Ok(()) diff --git a/crates/iam/src/sys.rs b/crates/iam/src/sys.rs index da02dd8bc..ca716cc47 100644 --- a/crates/iam/src/sys.rs +++ b/crates/iam/src/sys.rs @@ -79,6 +79,42 @@ enum PolicyPluginState { Failed, } +impl PolicyPluginState { + fn prepared_iam_auth(&self) -> Option { + match self { + Self::Ready(_) => Some(PreparedIamAuth { + needs_existing_object_tag: true, + mode: PreparedIamMode::Opa, + }), + Self::Initializing | Self::Failed => Some(PreparedIamAuth { + needs_existing_object_tag: false, + mode: PreparedIamMode::Deny, + }), + Self::Disabled => None, + } + } +} + +async fn resolve_policy_plugin_state() -> PolicyPluginState { + match opa::lookup_config().await { + Ok(conf) if conf.enable() => { + info!("OPA plugin enabled"); + PolicyPluginState::Ready(opa::AuthZPlugin::new(conf)) + } + Ok(_) => PolicyPluginState::Failed, + Err(e) => { + error!( + component = "iam", + subsystem = "policy_plugin", + result = "configuration_load_failed", + error_kind = e.kind(), + "OPA plugin configuration load failed" + ); + PolicyPluginState::Failed + } + } +} + static POLICY_PLUGIN_STATE: OnceLock>> = OnceLock::new(); fn get_policy_plugin_state() -> Arc> { @@ -93,23 +129,7 @@ fn get_policy_plugin_state() -> Arc> { if configured { let state = Arc::clone(&state); tokio::spawn(async move { - let next_state = match opa::lookup_config().await { - Ok(conf) if conf.enable() => { - info!("OPA plugin enabled"); - PolicyPluginState::Ready(opa::AuthZPlugin::new(conf)) - } - Ok(_) => PolicyPluginState::Failed, - Err(e) => { - error!( - component = "iam", - subsystem = "policy_plugin", - result = "configuration_load_failed", - error_kind = e.kind(), - "OPA plugin configuration load failed" - ); - PolicyPluginState::Failed - } - }; + let next_state = resolve_policy_plugin_state().await; *state.write().await = next_state; }); } @@ -1164,20 +1184,8 @@ impl IamSys { }; } - match Self::policy_plugin_state().await { - PolicyPluginState::Ready(_) => { - return PreparedIamAuth { - needs_existing_object_tag: true, - mode: PreparedIamMode::Opa, - }; - } - PolicyPluginState::Initializing | PolicyPluginState::Failed => { - return PreparedIamAuth { - needs_existing_object_tag: false, - mode: PreparedIamMode::Deny, - }; - } - PolicyPluginState::Disabled => {} + if let Some(prepared) = Self::policy_plugin_state().await.prepared_iam_auth() { + return prepared; } let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else { @@ -1840,6 +1848,8 @@ mod tests { sync::{Arc, Mutex}, }; use time::OffsetDateTime; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; #[test] fn test_combined_policy_for_view_returns_regular_policy() { @@ -1902,6 +1912,74 @@ mod tests { assert!(needs_secondary_tags, "OPA mode must request existing object tags for secondary actions"); } + #[tokio::test] + async fn test_prepare_auth_denies_while_policy_plugin_is_unavailable() { + let store = StsTestMockStore::new(false); + let iam_sys = IamSys::new(IamCache::new(store).await.expect("initialize IAM cache")); + let claims = HashMap::new(); + let groups = None; + let conditions = HashMap::new(); + let args = Args { + account: "opa-unavailable-test-user", + groups: &groups, + action: Action::S3Action(S3Action::ListAllMyBucketsAction), + bucket: "", + conditions: &conditions, + is_owner: false, + object: "", + claims: &claims, + deny_only: false, + }; + + let mut outcomes = Vec::new(); + for state in [PolicyPluginState::Initializing, PolicyPluginState::Failed] { + let prepared = state + .prepared_iam_auth() + .expect("unavailable policy plugin must prepare fail-closed IAM auth"); + outcomes.push(( + matches!(&prepared.mode, PreparedIamMode::Deny), + iam_sys.eval_prepared(&prepared, &args).await, + )); + } + + assert_eq!(outcomes, [(true, false), (true, false)]); + assert!(PolicyPluginState::Disabled.prepared_iam_auth().is_none()); + } + + #[tokio::test] + #[serial] + async fn test_policy_plugin_state_fails_after_opa_validation_returns_503() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind OPA validation test listener"); + let url = format!( + "http://{}/v1/data/rustfs/authz/allow", + listener.local_addr().expect("read listener address") + ); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept OPA validation connection"); + let mut request = [0_u8; 1024]; + let bytes = stream.read(&mut request).await.expect("read OPA validation request"); + assert!(bytes > 0, "OPA validation should send an HTTP request"); + stream + .write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .expect("write OPA unavailable response"); + }); + + let state = temp_env::async_with_vars( + [ + ("RUSTFS_POLICY_PLUGIN_URL", Some(url.as_str())), + ("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", None), + ], + resolve_policy_plugin_state(), + ) + .await; + server.await.expect("join OPA validation test server"); + + assert!(matches!(state, PolicyPluginState::Failed)); + } + const CUSTOM_STS_CLAIM_POLICY: &str = "custom-sts-claim-getobject"; const CUSTOM_STS_CLAIM_BUCKET: &str = "claim-bucket"; const CUSTOM_STS_CLAIM_POLICY_JSON: &str = r#"{ diff --git a/rustfs/src/app/bucket_usecase.rs b/rustfs/src/app/bucket_usecase.rs index 80d0b364a..8ae420f8e 100644 --- a/rustfs/src/app/bucket_usecase.rs +++ b/rustfs/src/app/bucket_usecase.rs @@ -16,7 +16,12 @@ use super::storage_api::bucket_usecase::ECStore; use super::storage_api::bucket_usecase::StorageObjectInfo as ObjectInfo; -use super::storage_api::bucket_usecase::access::{ReqInfo, authorize_request, bucket_config_mutation_incarnation, req_info_ref}; +#[cfg(test)] +use super::storage_api::bucket_usecase::access::ReqInfo; +use super::storage_api::bucket_usecase::access::{ + authorize_request, bucket_config_mutation_incarnation, log_list_buckets_iam_implicit_deny, + prepare_list_buckets_iam_authorization, req_info_ref, +}; #[cfg(test)] use super::storage_api::bucket_usecase::bucket::target::BucketTarget; use super::storage_api::bucket_usecase::bucket::{ @@ -70,7 +75,6 @@ use crate::auth::get_condition_values_with_client_info; use crate::error::ApiError; use crate::server::RemoteAddr; use crate::storage::storage_api::lock_bucket_targets_metadata; -use futures::StreamExt; use http::StatusCode; use metrics::counter; use rustfs_config::RUSTFS_REGION; @@ -1388,55 +1392,31 @@ impl DefaultBucketUsecase { return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); }; - let mut req = req; - if req.credentials.as_ref().is_none_or(|cred| cred.access_key.is_empty()) { return Err(S3Error::with_message(S3ErrorCode::AccessDenied, "Access Denied")); } - // The ListAllMyBuckets probe and the per-bucket probes cloned from this - // request treat denial as an expected filter outcome (issue #5740). - if let Some(req_info) = req.extensions.get_mut::() { - req_info.suppress_denial_log = true; - } - - let bucket_infos = if let Err(e) = authorize_request(&mut req, Action::S3Action(S3Action::ListAllMyBucketsAction)).await { - if e.code() != &S3ErrorCode::AccessDenied { - return Err(e); - } - - let mut list_bucket_infos = store.list_bucket(&BucketOptions::default()).await.map_err(ApiError::from)?; - - list_bucket_infos = futures::stream::iter(list_bucket_infos) - .filter_map(|info| async { - let mut req_clone = req.clone(); - let Some(req_info) = req_clone.extensions.get_mut::() else { - debug!(bucket = %info.name, "ReqInfo missing in extensions, skipping bucket authorization"); - return None; - }; - req_info.bucket = Some(info.name.clone()); - - if authorize_request(&mut req_clone, Action::S3Action(S3Action::ListBucketAction)) - .await - .is_ok() - || authorize_request(&mut req_clone, Action::S3Action(S3Action::GetBucketLocationAction)) - .await - .is_ok() - { - Some(info) - } else { - None - } - }) - .collect() - .await; - - if list_bucket_infos.is_empty() { - return Err(S3Error::with_message(S3ErrorCode::AccessDenied, "Access Denied")); - } - list_bucket_infos - } else { + let iam_authorization = prepare_list_buckets_iam_authorization(&req).await?; + let bucket_infos = if iam_authorization.is_allowed("", S3Action::ListAllMyBucketsAction).await { store.list_bucket(&BucketOptions::default()).await.map_err(ApiError::from)? + } else { + log_list_buckets_iam_implicit_deny(&req)?; + let bucket_infos = store.list_bucket(&BucketOptions::default()).await.map_err(ApiError::from)?; + let mut visible_bucket_infos = Vec::new(); + for info in bucket_infos { + if iam_authorization.is_allowed(&info.name, S3Action::ListBucketAction).await + || iam_authorization + .is_allowed(&info.name, S3Action::GetBucketLocationAction) + .await + { + visible_bucket_infos.push(info); + } + } + + if visible_bucket_infos.is_empty() { + return Err(ApiError::access_denied().into()); + } + visible_bucket_infos }; Ok(S3Response::new(build_list_buckets_output(&bucket_infos))) diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index 7a833e14a..902520998 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -215,10 +215,13 @@ pub(crate) mod runtime_sources { } pub(crate) mod access { + #[cfg(test)] + pub(crate) use crate::storage::storage_api::access_consumer::ReqInfo; pub(crate) use crate::storage::storage_api::access_consumer::{ - PostObjectRequestMarker, ReqInfo, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, - authorize_request, bucket_config_mutation_incarnation, has_bypass_governance_header, load_bucket_generation_from_store, - recursive_force_delete_is_authorized, replication_request_authorized, req_info_mut, req_info_ref, + PostObjectRequestMarker, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request, + bucket_config_mutation_incarnation, has_bypass_governance_header, load_bucket_generation_from_store, + log_list_buckets_iam_implicit_deny, prepare_list_buckets_iam_authorization, recursive_force_delete_is_authorized, + replication_request_authorized, req_info_mut, req_info_ref, }; } diff --git a/rustfs/src/storage/access.rs b/rustfs/src/storage/access.rs index 7b6df3620..f6152bf0f 100644 --- a/rustfs/src/storage/access.rs +++ b/rustfs/src/storage/access.rs @@ -32,7 +32,11 @@ use crate::storage::storage_api::runtime_sources_consumer::ServerContextSlot; use crate::storage::storage_api::runtime_sources_consumer::runtime_sources; use http::HeaderMap; use metrics::counter; -use rustfs_iam::error::Error as IamError; +use rustfs_iam::{ + error::Error as IamError, + store::object::ObjectStore, + sys::{IamSys, PreparedIamAuth}, +}; use rustfs_policy::policy::action::{Action, AdminAction, S3Action}; use rustfs_policy::policy::{ Args, BucketPolicy, BucketPolicyArgs, bucket_policy_needs_existing_object_tag_for_args, @@ -47,7 +51,7 @@ use rustfs_utils::http::{ use s3s::access::{S3Access, S3AccessContext}; use s3s::{S3Error, S3ErrorCode, S3Request, S3Result, dto::*, s3_error}; use std::collections::HashMap; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use url::{Url, form_urlencoded}; #[derive(Default, Clone, Debug)] @@ -585,6 +589,120 @@ fn auth_fs() -> &'static FS { AUTH_FS.get_or_init(FS::new) } +fn request_iam_store(req: &S3Request) -> S3Result>> { + let iam_store = match req.extensions.get::>() { + Some(server_ctx) => server_ctx + .installed_app_context() + .filter(|context| context.iam().is_ready()) + .map(|context| context.iam().handle()) + .ok_or(IamError::IamSysNotInitialized), + None => runtime_sources::current_ready_iam_handle(), + }; + iam_store.map_err(|_| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("authorize_request {:?}", IamError::IamSysNotInitialized), + ) + }) +} + +pub(crate) struct ListBucketsIamAuthorization { + iam_store: Arc>, + prepared: PreparedIamAuth, + account: String, + groups: Option>, + claims: HashMap, + is_owner: bool, + base_conditions: HashMap>, + bucket_conditions: HashMap>, +} + +impl ListBucketsIamAuthorization { + pub(crate) async fn is_allowed(&self, bucket: &str, action: S3Action) -> bool { + let conditions = if bucket.is_empty() { + &self.base_conditions + } else { + &self.bucket_conditions + }; + self.iam_store + .eval_prepared( + &self.prepared, + &Args { + account: &self.account, + groups: &self.groups, + action: Action::S3Action(action), + bucket, + conditions, + is_owner: self.is_owner, + object: "", + claims: &self.claims, + deny_only: false, + }, + ) + .await + } +} + +/// Prepare the IAM-only authorization used to decide which buckets are visible in ListBuckets. +/// Bucket policies are intentionally excluded from this discovery decision, matching MinIO. +pub(crate) async fn prepare_list_buckets_iam_authorization(req: &S3Request) -> S3Result { + let req_info = req_info_ref(req)?; + let Some(cred) = req_info.cred.as_ref() else { + return Err(ApiError::access_denied().into()); + }; + let iam_store = request_iam_store(req)?; + let account = cred.access_key.clone(); + let groups = cred.groups.clone(); + let claims = cred.claims.clone().unwrap_or_default(); + let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); + let client_info = req.extensions.get::(); + let action = Action::S3Action(S3Action::ListAllMyBucketsAction); + let base_conditions = authorization_conditions(req, cred, None, None, remote_addr, client_info, action)?; + let mut bucket_conditions = base_conditions.clone(); + bucket_conditions.insert("prefix".to_string(), vec![String::new()]); + bucket_conditions.insert("delimiter".to_string(), vec!["/".to_string()]); + let prepared = iam_store + .prepare_auth(&Args { + account: &account, + groups: &groups, + action, + bucket: "", + conditions: &base_conditions, + is_owner: req_info.is_owner, + object: "", + claims: &claims, + deny_only: false, + }) + .await; + + Ok(ListBucketsIamAuthorization { + iam_store, + prepared, + account, + groups, + claims, + is_owner: req_info.is_owner, + base_conditions, + bucket_conditions, + }) +} + +/// Preserve the top-level IAM denial audit emitted before ListBuckets falls back +/// to bucket-level visibility checks. +pub(crate) fn log_list_buckets_iam_implicit_deny(req: &S3Request) -> S3Result<()> { + let req_info = req_info_ref(req)?; + let denial = DenialContext { + quiet: true, + bucket: req_info.bucket.as_deref().unwrap_or_default(), + object: req_info.object.as_deref().unwrap_or_default(), + version_id: req_info.version_id.as_deref(), + account: req_info.cred.as_ref().map(|cred| cred.access_key.as_str()), + is_owner: req_info.is_owner, + }; + denial.log("iam_implicit_deny", Action::S3Action(S3Action::ListAllMyBucketsAction)); + Ok(()) +} + /// Extra action that may be evaluated in the same authorization flow and can /// independently require `ExistingObjectTag` conditions. fn secondary_tag_hint_action(action: Action, version_id: Option<&str>) -> Option { @@ -746,20 +864,7 @@ pub async fn authorize_request(req: &mut S3Request, action: Action) -> S3R }; if let Some(cred) = &cred { - let iam_store = match req.extensions.get::>() { - Some(server_ctx) => server_ctx - .installed_app_context() - .filter(|context| context.iam().is_ready()) - .map(|context| context.iam().handle()) - .ok_or(()), - None => runtime_sources::current_ready_iam_handle().map_err(|_| ()), - }; - let Ok(iam_store) = iam_store else { - return Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("authorize_request {:?}", IamError::IamSysNotInitialized), - )); - }; + let iam_store = request_iam_store(req)?; let default_claims = HashMap::new(); let claims = cred.claims.as_ref().unwrap_or(&default_claims); @@ -2615,8 +2720,7 @@ mod tests { use rustfs_policy::policy::{BucketPolicy, bucket_policy_uses_existing_object_tag_conditions}; use s3s::{S3ErrorCode, S3Request, dto::*}; use serial_test::serial; - use std::collections::HashMap; - use std::sync::Arc; + use std::{collections::HashMap, sync::Arc}; use time::OffsetDateTime; struct UnreadyIam; diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index bbd0c3796..5882e2a2b 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -108,7 +108,8 @@ pub(crate) mod access_consumer { pub(crate) use super::super::access::{ PostObjectRequestMarker, ReqInfo, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request, bucket_config_mutation_incarnation, has_bypass_governance_header, load_bucket_generation_from_store, - recursive_force_delete_is_authorized, replication_request_authorized, req_info_mut, req_info_ref, + log_list_buckets_iam_implicit_deny, prepare_list_buckets_iam_authorization, recursive_force_delete_is_authorized, + replication_request_authorized, req_info_mut, req_info_ref, }; }