mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-20 19:42:17 +00:00
fix(auth): align ListBuckets discovery with IAM policies (#5746)
This commit is contained in:
@@ -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::<ReqInfo>() {
|
||||
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::<ReqInfo>() 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)))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+122
-18
@@ -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<T>(req: &S3Request<T>) -> S3Result<Arc<IamSys<ObjectStore>>> {
|
||||
let iam_store = match req.extensions.get::<Arc<ServerContextSlot>>() {
|
||||
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<IamSys<ObjectStore>>,
|
||||
prepared: PreparedIamAuth,
|
||||
account: String,
|
||||
groups: Option<Vec<String>>,
|
||||
claims: HashMap<String, serde_json::Value>,
|
||||
is_owner: bool,
|
||||
base_conditions: HashMap<String, Vec<String>>,
|
||||
bucket_conditions: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
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<T>(req: &S3Request<T>) -> S3Result<ListBucketsIamAuthorization> {
|
||||
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::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
let client_info = req.extensions.get::<ClientInfo>();
|
||||
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<T>(req: &S3Request<T>) -> 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<Action> {
|
||||
@@ -746,20 +864,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
};
|
||||
|
||||
if let Some(cred) = &cred {
|
||||
let iam_store = match req.extensions.get::<std::sync::Arc<ServerContextSlot>>() {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user