Files
rustfs/rustfs/src/storage/access.rs
T

5992 lines
249 KiB
Rust

// 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 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, 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, normalize_put_read_quorum_error};
use crate::license::license_check;
use crate::shared_types::RemoteAddr;
use crate::storage::request_context::RequestContext;
use crate::storage::storage_api::contract::bucket::BUCKET_LIFECYCLE_LOCK_OBJECT;
use crate::storage::storage_api::contract::namespace::NamespaceLocking as _;
use crate::storage::storage_api::runtime_sources_consumer::ServerContextSlot;
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
use chacha20poly1305::{
ChaCha20Poly1305, KeyInit,
aead::{Aead, Payload},
};
use http::HeaderMap;
use metrics::counter;
use rand::Rng;
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,
bucket_policy_uses_existing_object_tag_conditions,
};
use rustfs_trusted_proxies::ClientInfo;
use rustfs_utils::http::{
AMZ_BUCKET_REPLICATION_STATUS, AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE,
SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_SOURCE_ETAG, SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_CHECK,
SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID, force_delete_header, get_header,
};
use s3s::access::{S3Access, S3AccessContext};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Result, dto::*, s3_error};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
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";
const LOG_COMPONENT_ACCESS: &str = "storage_access";
const LOG_SUBSYSTEM_AUTHORIZATION: &str = "authorization";
#[derive(Default, Clone, Debug)]
pub(crate) struct ReqInfo {
pub cred: Option<rustfs_credentials::Credentials>,
pub is_owner: bool,
pub bucket: Option<String>,
pub object: Option<String>,
pub version_id: Option<String>,
pub replication_request_authorized: bool,
#[allow(dead_code, reason = "written but never read back (backlog#1823)")]
pub region: Option<s3s::region::Region>,
pub request_context: Option<RequestContext>,
/// Set by probe-style callers that treat AccessDenied as an expected filter
/// outcome (ListBuckets per-bucket fallback, ListObjects metadata permission
/// collection) so `authorize_request` logs those routine denials at `debug`
/// instead of `warn` (issue #5740).
pub suppress_denial_log: bool,
}
pub(crate) fn replication_request_authorized<T>(req: &S3Request<T>) -> bool {
req.extensions
.get::<ReqInfo>()
.is_some_and(|req_info| req_info.replication_request_authorized)
}
fn has_replication_only_put_headers(headers: &HeaderMap) -> bool {
headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS)
|| get_header(headers, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE).is_some()
|| get_header(headers, SUFFIX_REPLICATION_SSEC_CRC).is_some()
|| get_header(headers, SUFFIX_SOURCE_ETAG).is_some()
|| get_header(headers, SUFFIX_SOURCE_MTIME).is_some()
|| get_header(headers, SUFFIX_SOURCE_REPLICATION_CHECK).is_some()
|| get_header(headers, SUFFIX_SOURCE_REPLICATION_REQUEST).is_some()
|| get_header(headers, SUFFIX_SOURCE_VERSION_ID).is_some()
|| rustfs_utils::http::has_ssec_transport_headers(headers)
}
async fn authorize_replication_only_put_headers<T>(req: &mut S3Request<T>) -> S3Result<()> {
if !has_replication_only_put_headers(&req.headers) {
return Ok(());
}
authorize_request(req, Action::S3Action(S3Action::ReplicateObjectAction)).await?;
req_info_mut(req)?.replication_request_authorized = true;
Ok(())
}
pub(crate) fn recursive_force_delete_has_authenticated_caller(
headers: &HeaderMap,
authenticated: bool,
replica_request: bool,
) -> bool {
match force_delete_header(headers) {
Ok(Some(true)) => authenticated || replica_request,
_ => true,
}
}
fn invalid_force_delete_header() -> S3Error {
S3Error::with_message(S3ErrorCode::InvalidRequest, "Invalid force-delete header value")
}
#[derive(Clone, Debug)]
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 RequestedObjectLockRetention(ObjectLockRetention);
#[derive(Clone, Debug)]
struct StagedMultipartPartAuthorization;
#[derive(Clone, Default)]
pub(crate) struct TableDataPlanePublicationGuards {
state: Arc<parking_lot::Mutex<TableDataPlanePublicationState>>,
}
#[derive(Default)]
struct TableDataPlanePublicationState {
keys: BTreeSet<(String, String)>,
guards: Vec<Box<dyn Send>>,
lock_lost_signals: Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
resources: HashMap<(String, String), crate::table_catalog::TableDataPlaneResource>,
missing_resources: BTreeSet<(String, String)>,
}
impl TableDataPlanePublicationGuards {
pub(crate) fn add_lock_loss_fences(&self, options: &mut ObjectOptions) {
let state = self.state.lock();
for signal in &state.lock_lost_signals {
options.add_namespace_lock_lost_signal(Arc::clone(signal));
}
}
}
pub(crate) const TABLE_DATA_PLANE_LIST_CURSOR_PREFIX: &str = "rustfs-table-list:v1:";
const TABLE_DATA_PLANE_LIST_CURSOR_VERSION: u8 = 1;
const TABLE_DATA_PLANE_LIST_CURSOR_KEY_CONTEXT: &[u8] = b"rustfs-table-list-cursor-key:v1";
const TABLE_DATA_PLANE_LIST_CURSOR_MAX_ENCODED_LEN: usize = 16 * 1024;
fn table_data_plane_list_internal_error(message: impl Into<String>) -> S3Error {
S3Error::with_message(S3ErrorCode::InternalError, message.into())
}
#[derive(Clone)]
struct TableDataPlaneListResource {
resource: crate::table_catalog::TableDataPlaneResource,
allowed: bool,
}
#[derive(Clone)]
pub(crate) struct TableDataPlaneListAccess {
bucket: String,
principal_fingerprint: String,
snapshot_fingerprint: String,
cursor_key: [u8; 32],
resources: Vec<TableDataPlaneListResource>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TableDataPlaneListCursor {
version: u8,
bucket: String,
principal_fingerprint: String,
snapshot_fingerprint: String,
operation: String,
prefix: String,
delimiter: Option<String>,
max_keys: usize,
marker: Option<String>,
version_marker: Option<String>,
common_prefix: Option<String>,
}
pub(crate) struct TableDataPlaneListCursorPosition {
pub(crate) marker: Option<String>,
pub(crate) version_marker: Option<String>,
pub(crate) common_prefix: Option<String>,
}
impl TableDataPlaneListCursor {
pub(crate) fn marker(&self) -> Option<&str> {
self.marker.as_deref()
}
pub(crate) fn version_marker(&self) -> Option<&str> {
self.version_marker.as_deref()
}
pub(crate) fn common_prefix(&self) -> Option<&str> {
self.common_prefix.as_deref()
}
}
impl TableDataPlaneListAccess {
pub(crate) fn allows_object(&self, object: &str) -> bool {
if crate::table_catalog::is_reserved_table_object_key(object) {
return false;
}
self.resources
.iter()
.find(|entry| object.starts_with(&entry.resource.warehouse_object_prefix))
.is_none_or(|entry| entry.allowed)
}
pub(crate) fn encode_cursor(
&self,
operation: &str,
prefix: &str,
delimiter: Option<&str>,
max_keys: usize,
position: TableDataPlaneListCursorPosition,
) -> S3Result<String> {
let cursor = TableDataPlaneListCursor {
version: TABLE_DATA_PLANE_LIST_CURSOR_VERSION,
bucket: self.bucket.clone(),
principal_fingerprint: self.principal_fingerprint.clone(),
snapshot_fingerprint: self.snapshot_fingerprint.clone(),
operation: operation.to_string(),
prefix: prefix.to_string(),
delimiter: delimiter.map(str::to_string),
max_keys,
marker: position.marker,
version_marker: position.version_marker,
common_prefix: position.common_prefix,
};
let plaintext = serde_json::to_vec(&cursor).map_err(|err| {
table_data_plane_list_internal_error(format!("failed to encode protected table list cursor: {err}"))
})?;
let cipher = ChaCha20Poly1305::new_from_slice(&self.cursor_key)
.map_err(|_| table_data_plane_list_internal_error("failed to initialize protected table list cursor cipher"))?;
let mut nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = chacha20poly1305::Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: &plaintext,
aad: TABLE_DATA_PLANE_LIST_CURSOR_PREFIX.as_bytes(),
},
)
.map_err(|_| table_data_plane_list_internal_error("failed to seal protected table list cursor"))?;
let mut token = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
token.extend_from_slice(&nonce_bytes);
token.extend_from_slice(&ciphertext);
Ok(format!(
"{}{}",
TABLE_DATA_PLANE_LIST_CURSOR_PREFIX,
base64_simd::URL_SAFE_NO_PAD.encode_to_string(token)
))
}
pub(crate) fn decode_cursor(
&self,
operation: &str,
prefix: &str,
delimiter: Option<&str>,
max_keys: usize,
token: &str,
) -> S3Result<TableDataPlaneListCursor> {
if token.len() > TABLE_DATA_PLANE_LIST_CURSOR_MAX_ENCODED_LEN {
return Err(invalid_table_data_plane_list_cursor());
}
let encoded = token
.strip_prefix(TABLE_DATA_PLANE_LIST_CURSOR_PREFIX)
.ok_or_else(invalid_table_data_plane_list_cursor)?;
let sealed = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(encoded.as_bytes())
.map_err(|_| invalid_table_data_plane_list_cursor())?;
let (nonce_bytes, ciphertext) = sealed
.split_at_checked(12)
.filter(|(_, ciphertext)| ciphertext.len() >= 16)
.ok_or_else(invalid_table_data_plane_list_cursor)?;
let cipher = ChaCha20Poly1305::new_from_slice(&self.cursor_key)
.map_err(|_| table_data_plane_list_internal_error("failed to initialize protected table list cursor cipher"))?;
let nonce = chacha20poly1305::Nonce::try_from(nonce_bytes).map_err(|_| invalid_table_data_plane_list_cursor())?;
let plaintext = cipher
.decrypt(
&nonce,
Payload {
msg: ciphertext,
aad: TABLE_DATA_PLANE_LIST_CURSOR_PREFIX.as_bytes(),
},
)
.map_err(|_| invalid_table_data_plane_list_cursor())?;
let cursor: TableDataPlaneListCursor =
serde_json::from_slice(&plaintext).map_err(|_| invalid_table_data_plane_list_cursor())?;
if cursor.version != TABLE_DATA_PLANE_LIST_CURSOR_VERSION
|| cursor.bucket != self.bucket
|| cursor.principal_fingerprint != self.principal_fingerprint
|| cursor.snapshot_fingerprint != self.snapshot_fingerprint
|| cursor.operation != operation
|| cursor.prefix != prefix
|| cursor.delimiter.as_deref() != delimiter
|| cursor.max_keys != max_keys
{
return Err(invalid_table_data_plane_list_cursor());
}
Ok(cursor)
}
}
fn invalid_table_data_plane_list_cursor() -> S3Error {
S3Error::with_message(S3ErrorCode::InvalidArgument, "Invalid table listing continuation token".to_string())
}
#[derive(Clone, Debug)]
pub(crate) struct BucketGenerationGuard {
bucket: String,
incarnation_id: uuid::Uuid,
}
#[derive(Clone, Debug)]
pub(crate) struct BucketConfigMutationSnapshot {
bucket: String,
incarnation_id: uuid::Uuid,
}
pub(crate) fn bucket_config_mutation_incarnation<T>(req: &S3Request<T>, bucket: &str) -> S3Result<Option<uuid::Uuid>> {
let Some(snapshot) = req.extensions.get::<BucketConfigMutationSnapshot>() else {
if req.extensions.get::<std::sync::Arc<ServerContextSlot>>().is_some() {
return Err(s3_error!(InternalError, "bucket config mutation snapshot is missing"));
}
return Ok(None);
};
if snapshot.bucket != bucket {
return Err(s3_error!(InternalError, "bucket config mutation snapshot does not match request bucket"));
}
Ok(Some(snapshot.incarnation_id))
}
async fn load_bucket_config_mutation_snapshot<T>(
fs: &FS,
req: &S3Request<T>,
bucket: &str,
) -> S3Result<BucketConfigMutationSnapshot> {
let store = fs
.server_ctx()
.object_store()
.ok_or_else(|| s3_error!(InternalError, "object store is not initialized"))?;
let lock = store
.new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT)
.await
.map_err(ApiError::from)?;
let _lifecycle_guard = lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(|err| {
ApiError::from(match err {
rustfs_lock::LockError::QuorumNotReached { required, achieved } => StorageError::NamespaceLockQuorumUnavailable {
mode: "bucket_config_mutation",
bucket: bucket.to_string(),
object: BUCKET_LIFECYCLE_LOCK_OBJECT.to_string(),
required,
achieved,
},
other => StorageError::Lock(other),
})
})?;
let incarnation_id = load_bucket_generation_from_store(store.as_ref(), req, bucket)
.await?
.incarnation_id;
Ok(BucketConfigMutationSnapshot {
bucket: bucket.to_string(),
incarnation_id,
})
}
async fn authorize_bucket_config_mutation<T>(fs: &FS, req: &mut S3Request<T>, action: Action) -> S3Result<()> {
let bucket = req_info_ref(req)?
.bucket
.clone()
.ok_or_else(|| s3_error!(InternalError, "bucket config mutation request has no bucket"))?;
let snapshot = load_bucket_config_mutation_snapshot(fs, req, &bucket).await;
authorize_request(req, action).await?;
req.extensions.insert(snapshot?);
Ok(())
}
#[derive(Clone, Debug)]
struct CopySourceBucketGenerationGuard {
bucket: String,
incarnation_id: uuid::Uuid,
}
#[cfg(test)]
type ObjectGenerationTestHook = (String, tokio::sync::oneshot::Sender<()>, tokio::sync::oneshot::Receiver<()>);
#[cfg(test)]
static OBJECT_GENERATION_TEST_HOOK: OnceLock<std::sync::Mutex<Option<ObjectGenerationTestHook>>> = OnceLock::new();
#[cfg(test)]
tokio::task_local! {
static PUT_GENERATION_READ_QUORUM_FAILURE: ();
}
#[cfg(test)]
fn install_object_generation_test_hook(
bucket: String,
loaded: tokio::sync::oneshot::Sender<()>,
resume: tokio::sync::oneshot::Receiver<()>,
) {
*OBJECT_GENERATION_TEST_HOOK
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("object generation test hook lock should not be poisoned") = Some((bucket, loaded, resume));
}
#[cfg(test)]
async fn wait_for_object_generation_test_hook(bucket: &str) {
let hook = {
let mut slot = OBJECT_GENERATION_TEST_HOOK
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("object generation test hook lock should not be poisoned");
if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) {
slot.take()
} else {
None
}
};
if let Some((_bucket, loaded, resume)) = hook {
let _ = loaded.send(());
let _ = resume.await;
}
}
#[derive(Clone, Debug)]
enum PendingDeleteBucketGenerationGuard {
Ready(BucketGenerationGuard),
Failed { code: S3ErrorCode, message: String },
}
impl PendingDeleteBucketGenerationGuard {
fn from_result(result: S3Result<BucketGenerationGuard>) -> Self {
match result {
Ok(guard) => Self::Ready(guard),
Err(err) => Self::Failed {
code: err.code().clone(),
message: err.message().unwrap_or_else(|| err.code().as_str()).to_string(),
},
}
}
}
pub(crate) async fn load_bucket_generation_from_store<T>(
store: &crate::storage::storage_api::ECStore,
req: &S3Request<T>,
bucket: &str,
) -> S3Result<BucketGenerationGuard> {
if let Some(existing) = req.extensions.get::<BucketGenerationGuard>() {
return if existing.bucket == bucket {
Ok(existing.clone())
} else {
Err(s3_error!(InternalError, "bucket generation guard does not match request bucket"))
};
}
let incarnation_id = store.bucket_incarnation_id(bucket).await.map_err(ApiError::from)?;
Ok(BucketGenerationGuard {
bucket: bucket.to_string(),
incarnation_id,
})
}
async fn load_bucket_generation<T>(fs: &FS, req: &S3Request<T>, bucket: &str) -> S3Result<BucketGenerationGuard> {
let store = fs
.server_ctx()
.object_store()
.ok_or_else(|| s3_error!(InternalError, "object store is not initialized"))?;
load_bucket_generation_from_store(store.as_ref(), req, bucket).await
}
/// A read may consult only the source admitted before its authorization.
/// Capture failures are deferred until a local miss actually needs that source.
#[derive(Clone, Debug)]
enum OdmReadGenerationGuard {
Unavailable,
Ready(BucketGenerationGuard),
Failed { code: S3ErrorCode, message: String },
}
impl OdmReadGenerationGuard {
fn from_result(result: S3Result<BucketGenerationGuard>) -> Self {
match result {
Ok(guard) => Self::Ready(guard),
Err(err) => Self::Failed {
code: err.code().clone(),
message: err.message().unwrap_or_else(|| err.code().as_str()).to_string(),
},
}
}
}
fn odm_read_source_configured(bucket: &str) -> bool {
let sys = crate::on_demand_migration::OnDemandMigrationSys::get();
sys.is_module_enabled() && sys.state(bucket).is_some()
}
async fn capture_odm_read_generation<T>(fs: &FS, req: &S3Request<T>, bucket: &str) -> OdmReadGenerationGuard {
if !odm_read_source_configured(bucket) {
return OdmReadGenerationGuard::Unavailable;
}
OdmReadGenerationGuard::from_result(load_bucket_generation(fs, req, bucket).await)
}
/// Direct usecase callers have no access middleware; capture at their entry.
/// A server request without the access marker must never bind to a later source.
pub(crate) async fn prepare_odm_read_generation<T>(
store: &crate::storage::storage_api::ECStore,
req: &mut S3Request<T>,
bucket: &str,
) {
if req.extensions.get::<OdmReadGenerationGuard>().is_some() {
return;
}
let guard = if req.extensions.get::<std::sync::Arc<ServerContextSlot>>().is_some() || !odm_read_source_configured(bucket) {
OdmReadGenerationGuard::Unavailable
} else {
OdmReadGenerationGuard::from_result(load_bucket_generation_from_store(store, req, bucket).await)
};
req.extensions.insert(guard);
}
pub(crate) fn odm_read_generation<T>(req: &S3Request<T>, bucket: &str) -> S3Result<Option<uuid::Uuid>> {
match req.extensions.get::<OdmReadGenerationGuard>() {
Some(OdmReadGenerationGuard::Ready(guard)) if guard.bucket == bucket => Ok(Some(guard.incarnation_id)),
Some(OdmReadGenerationGuard::Ready(_)) => {
Err(s3_error!(InternalError, "source generation guard does not match request bucket"))
}
Some(OdmReadGenerationGuard::Failed { code, message }) => Err(S3Error::with_message(code.clone(), message.clone())),
Some(OdmReadGenerationGuard::Unavailable) | None => Ok(None),
}
}
async fn load_copy_source_bucket_generation(fs: &FS, bucket: &str) -> S3Result<CopySourceBucketGenerationGuard> {
let store = fs
.server_ctx()
.object_store()
.ok_or_else(|| s3_error!(InternalError, "object store is not initialized"))?;
let incarnation_id = store.bucket_incarnation_id(bucket).await.map_err(ApiError::from)?;
Ok(CopySourceBucketGenerationGuard {
bucket: bucket.to_string(),
incarnation_id,
})
}
pub(crate) fn apply_bucket_generation_guard<T>(req: &S3Request<T>, bucket: &str, opts: &mut ObjectOptions) -> S3Result<()> {
let pending_guard = req.extensions.get::<PendingDeleteBucketGenerationGuard>();
let guard = match pending_guard {
Some(PendingDeleteBucketGenerationGuard::Ready(guard)) => Some(guard),
Some(PendingDeleteBucketGenerationGuard::Failed { code, message }) => {
return Err(S3Error::with_message(code.clone(), message.clone()));
}
None => req.extensions.get::<BucketGenerationGuard>(),
};
let Some(guard) = guard else {
if req.extensions.get::<std::sync::Arc<ServerContextSlot>>().is_some() {
return Err(s3_error!(InternalError, "bucket generation guard is missing"));
}
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
publication_guards.add_lock_loss_fences(opts);
}
return Ok(());
};
if guard.bucket != bucket {
return Err(s3_error!(InternalError, "bucket generation guard does not match request bucket"));
}
opts.expected_bucket_incarnation_id = Some(guard.incarnation_id);
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
publication_guards.add_lock_loss_fences(opts);
}
Ok(())
}
pub(crate) fn retained_table_data_plane_publication_guards<T>(req: &S3Request<T>) -> Option<TableDataPlanePublicationGuards> {
req.extensions.get::<TableDataPlanePublicationGuards>().cloned()
}
pub(crate) fn apply_copy_source_bucket_generation_guard<T>(
req: &S3Request<T>,
bucket: &str,
opts: &mut ObjectOptions,
) -> S3Result<()> {
let Some(guard) = req.extensions.get::<CopySourceBucketGenerationGuard>() else {
if req.extensions.get::<std::sync::Arc<ServerContextSlot>>().is_some() {
return Err(s3_error!(InternalError, "copy source bucket generation guard is missing"));
}
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
publication_guards.add_lock_loss_fences(opts);
}
return Ok(());
};
if guard.bucket != bucket {
return Err(s3_error!(
InternalError,
"copy source bucket generation guard does not match request bucket"
));
}
opts.expected_bucket_incarnation_id = Some(guard.incarnation_id);
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
publication_guards.add_lock_loss_fences(opts);
}
Ok(())
}
pub(crate) fn req_info_ref<T>(req: &S3Request<T>) -> S3Result<&ReqInfo> {
req.extensions
.get::<ReqInfo>()
.ok_or_else(|| s3_error!(InternalError, "ReqInfo not found in request extensions"))
}
pub(crate) fn req_info_mut<T>(req: &mut S3Request<T>) -> S3Result<&mut ReqInfo> {
req.extensions
.get_mut::<ReqInfo>()
.ok_or_else(|| s3_error!(InternalError, "ReqInfo not found in request extensions"))
}
fn ext_req_info_mut(ext: &mut http::Extensions) -> S3Result<&mut ReqInfo> {
ext.get_mut::<ReqInfo>()
.ok_or_else(|| s3_error!(InternalError, "ReqInfo not found in request extensions"))
}
/// Extract the canonical `RequestContext` from a request, checking both
/// the request extensions directly and the `ReqInfo.request_context` field.
pub(crate) fn request_context_from_req<T>(req: &S3Request<T>) -> Option<RequestContext> {
request_context_from_extensions(&req.extensions)
}
/// Same lookup against the extensions alone, for callers that have already
/// moved a field out of the request and can no longer borrow it whole.
pub(crate) fn request_context_from_extensions(extensions: &http::Extensions) -> Option<RequestContext> {
extensions
.get::<RequestContext>()
.cloned()
.or_else(|| extensions.get::<ReqInfo>().and_then(|ri| ri.request_context.clone()))
}
#[derive(Clone, Debug)]
pub(crate) struct ObjectTagConditions {
bucket: String,
object: String,
version_id: Option<String>,
values: HashMap<String, Vec<String>>,
}
impl ObjectTagConditions {
fn new(bucket: &str, object: &str, version_id: Option<&str>, values: HashMap<String, Vec<String>>) -> Self {
Self {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: version_id.map(str::to_string),
values,
}
}
fn matches(&self, bucket: &str, object: &str, version_id: Option<&str>) -> bool {
self.bucket == bucket && self.object == object && self.version_id.as_deref() == version_id
}
}
const AMZ_WRITE_OFFSET_BYTES_HEADER: &str = "x-amz-write-offset-bytes";
fn has_write_offset_bytes_header(headers: &http::HeaderMap) -> bool {
headers.contains_key(AMZ_WRITE_OFFSET_BYTES_HEADER)
}
/// True when the bucket policy may evaluate `s3:ExistingObjectTag` for this request (statement
/// matches principal/action/resource and conditions reference ExistingObjectTag keys).
enum BucketPolicyExistingObjectTagHint {
NoTagRequirement,
ConservativeTagRequired,
Parsed(BucketPolicy),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BucketPolicyRawLoadErrorKind {
PolicyMissing,
BucketMissing,
Other,
}
fn classify_bucket_policy_raw_load_error(err: &StorageError) -> BucketPolicyRawLoadErrorKind {
if err == &StorageError::ConfigNotFound {
BucketPolicyRawLoadErrorKind::PolicyMissing
} else if is_err_bucket_not_found(err) {
BucketPolicyRawLoadErrorKind::BucketMissing
} else {
BucketPolicyRawLoadErrorKind::Other
}
}
/// Load and parse bucket policy once for ExistingObjectTag hint checks.
async fn load_bucket_policy_existing_object_tag_hint(
store: &ECStore,
bucket: &str,
action: Action,
) -> BucketPolicyExistingObjectTagHint {
let (policy_str, _) = match store.get_bucket_policy_raw(bucket).await {
Ok(v) => v,
Err(err) => match classify_bucket_policy_raw_load_error(&err) {
BucketPolicyRawLoadErrorKind::PolicyMissing => {
tracing::debug!(
bucket = %bucket,
?action,
"bucket policy not configured while checking ExistingObjectTag hint; treating as no tag requirement"
);
return BucketPolicyExistingObjectTagHint::NoTagRequirement;
}
BucketPolicyRawLoadErrorKind::BucketMissing => {
tracing::debug!(
bucket = %bucket,
?action,
error = %err,
"bucket missing while checking ExistingObjectTag hint; treating as no tag requirement"
);
return BucketPolicyExistingObjectTagHint::NoTagRequirement;
}
BucketPolicyRawLoadErrorKind::Other => {
tracing::warn!(
bucket = %bucket,
?action,
error = %err,
"failed to load bucket policy while checking ExistingObjectTag hint; conservatively enabling tag fetch"
);
return BucketPolicyExistingObjectTagHint::ConservativeTagRequired;
}
},
};
match serde_json::from_str::<BucketPolicy>(policy_str.as_str()) {
Ok(policy) => {
if bucket_policy_uses_existing_object_tag_conditions(&policy) {
BucketPolicyExistingObjectTagHint::Parsed(policy)
} else {
BucketPolicyExistingObjectTagHint::NoTagRequirement
}
}
Err(err) => {
tracing::warn!(
bucket = %bucket,
?action,
error = %err,
"malformed bucket policy while checking ExistingObjectTag hint; conservatively enabling tag fetch"
);
BucketPolicyExistingObjectTagHint::ConservativeTagRequired
}
}
}
async fn bucket_policy_needs_existing_object_tag_from_hint(
hint: &BucketPolicyExistingObjectTagHint,
args: &BucketPolicyArgs<'_>,
) -> bool {
match hint {
BucketPolicyExistingObjectTagHint::NoTagRequirement => false,
BucketPolicyExistingObjectTagHint::ConservativeTagRequired => true,
BucketPolicyExistingObjectTagHint::Parsed(policy) => bucket_policy_needs_existing_object_tag_for_args(policy, args).await,
}
}
fn merge_object_tag_conditions(conditions: &mut HashMap<String, Vec<String>>, tags: &HashMap<String, Vec<String>>) {
for (k, v) in tags {
conditions
.entry(k.clone())
.and_modify(|existing| existing.extend(v.iter().cloned()))
.or_insert_with(|| v.clone());
}
}
fn action_tag_metric_label(action: &Action) -> &'static str {
match action {
Action::S3Action(S3Action::GetObjectAction) => "get_object",
Action::S3Action(S3Action::GetObjectAttributesAction) => "get_object_attributes",
Action::S3Action(S3Action::GetObjectVersionAction) => "get_object_version",
Action::S3Action(S3Action::GetObjectVersionAttributesAction) => "get_object_version_attributes",
Action::S3Action(S3Action::GetObjectTaggingAction) => "get_object_tagging",
Action::S3Action(S3Action::DeleteObjectAction) => "delete_object",
Action::S3Action(S3Action::DeleteObjectVersionAction) => "delete_object_version",
Action::S3Action(S3Action::DeleteObjectTaggingAction) => "delete_object_tagging",
Action::S3Action(S3Action::PutObjectTaggingAction) => "put_object_tagging",
_ => "authorize",
}
}
fn merge_list_bucket_query_conditions(action: Action, query: Option<&str>, conditions: &mut HashMap<String, Vec<String>>) {
if !matches!(
action,
Action::S3Action(
S3Action::ListBucketAction | S3Action::ListBucketVersionsAction | S3Action::ListBucketMultipartUploadsAction
)
) {
return;
}
let Some(query) = query else {
return;
};
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"prefix" | "delimiter" | "max-keys" => {
conditions.entry(key.into_owned()).or_default().push(value.into_owned());
}
_ => {}
}
}
}
fn merge_request_object_tag_conditions(
action: Action,
headers: &HeaderMap,
conditions: &mut HashMap<String, Vec<String>>,
) -> S3Result<()> {
if action != Action::S3Action(S3Action::PutObjectAction) {
return Ok(());
}
let Some(tagging) = headers.get("x-amz-tagging").and_then(|value| value.to_str().ok()) else {
return Ok(());
};
let mut tag_keys = Vec::new();
for tag in crate::storage::s3_api::tagging::parse_object_tag_header(tagging)? {
let Some(key) = tag.key else {
continue;
};
let Some(value) = tag.value else {
continue;
};
tag_keys.push(key.clone());
conditions.entry(format!("RequestObjectTag/{key}")).or_default().push(value);
}
conditions.insert("RequestObjectTagKeys".to_string(), tag_keys);
Ok(())
}
fn authorization_conditions<T>(
req: &S3Request<T>,
cred: &rustfs_credentials::Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<std::net::SocketAddr>,
client_info: Option<&ClientInfo>,
action: Action,
) -> S3Result<HashMap<String, Vec<String>>> {
let mut conditions = get_condition_values_with_query_and_client_info(
&req.headers,
cred,
version_id,
region,
remote_addr,
req.uri.query(),
client_info,
);
if req.extensions.get::<InternalObjectAuthorization>().is_some() {
retain_internal_object_authorization_conditions(&mut conditions, cred);
return Ok(conditions);
}
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
merge_request_object_tag_conditions(action, &req.headers, &mut conditions)?;
if let Some(RequestedObjectLockRetention(retention)) = req.extensions.get::<RequestedObjectLockRetention>() {
// Standalone retention writes apply the XML body, so headers must not
// supply a different value to the policy decision.
conditions.remove("object-lock-mode");
conditions.remove("object-lock-retain-until-date");
if let Some(mode) = &retention.mode {
conditions.insert("object-lock-mode".to_string(), vec![mode.as_str().to_string()]);
}
if let Some(date) = &retention.retain_until_date {
let date = OffsetDateTime::from(date.clone())
.format(&time::format_description::well_known::Rfc3339)
.map_err(|_| S3Error::with_message(S3ErrorCode::InvalidArgument, "Invalid retain-until date"))?;
conditions.insert("object-lock-retain-until-date".to_string(), vec![date]);
}
}
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 set_requested_object_lock_retention(req: &mut S3Request<PutObjectRetentionInput>) {
let retention = req.input.retention.clone().unwrap_or_default();
set_requested_object_lock_retain_until(req, retention.retain_until_date.as_ref());
req.extensions.insert(RequestedObjectLockRetention(retention));
}
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,
) {
conditions.retain(|key, _| {
matches!(
key.as_str(),
"CurrentTime"
| "EpochTime"
| "SecureTransport"
| "SourceIp"
| "UserAgent"
| "Referer"
| "userid"
| "username"
| "principaltype"
| "signatureversion"
| "signatureAge"
| "authType"
| "LocationConstraint"
| "groups"
| "roles"
) || cred.claims.as_ref().is_some_and(|claims| {
claims
.keys()
.any(|claim| claim.trim_start_matches("ldap").to_lowercase() == *key)
})
});
}
/// Condition values for an authorization decision that is not scoped to a bucket or object.
///
/// Lives here rather than at the call site because this module already owns the request
/// plumbing every such decision needs: the verified client address and `ReqInfo`. The KMS
/// grammar has no bucket/object/tag conditions, so none of the S3 merges apply.
pub(crate) fn resource_free_condition_values<T>(
req: &S3Request<T>,
cred: &rustfs_credentials::Credentials,
) -> HashMap<String, Vec<String>> {
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
get_condition_values_with_client_info(&req.headers, cred, None, None, remote_addr, req.extensions.get::<ClientInfo>())
}
fn request_object_store<T>(req: &S3Request<T>) -> S3Result<Arc<ECStore>> {
match req.extensions.get::<Arc<ServerContextSlot>>() {
Some(server_ctx) => server_ctx
.installed_object_store()
.ok_or_else(object_store_not_initialized_error),
None => runtime_sources::current_object_store_handle().ok_or_else(object_store_not_initialized_error),
}
}
fn object_store_not_initialized_error() -> S3Error {
S3Error::with_message(S3ErrorCode::InternalError, "object store is not initialized")
}
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(())
}
pub(crate) fn delete_object_authorize_action(version_id: Option<&str>) -> Action {
Action::S3Action(if version_id.is_some() {
S3Action::DeleteObjectVersionAction
} else {
S3Action::DeleteObjectAction
})
}
/// GHSA-3ppv: select the IAM action for an object read by whether the request
/// names an explicit object version. A read that targets a specific version must
/// authorize against `s3:GetObjectVersion`; a current-object read authorizes
/// against `s3:GetObject`. Reading a historical version while only holding
/// `s3:GetObject` is an information-disclosure bypass, so the two must not be
/// conflated at the authorization boundary.
///
/// Note: `ActionSet::is_match` still maps a `s3:GetObjectVersion` grant onto a
/// `s3:GetObject` request. It only broadens a Version grant toward current
/// reads, never the reverse.
fn versioned_read_action(version_id: Option<&str>) -> Action {
if version_id.is_some() {
Action::S3Action(S3Action::GetObjectVersionAction)
} else {
Action::S3Action(S3Action::GetObjectAction)
}
}
fn versioned_tagging_action(action: S3Action, version_id: Option<&str>) -> Action {
let action = if version_id.is_some() {
match action {
S3Action::GetObjectTaggingAction => S3Action::GetObjectVersionTaggingAction,
S3Action::PutObjectTaggingAction => S3Action::PutObjectVersionTaggingAction,
S3Action::DeleteObjectTaggingAction => S3Action::DeleteObjectVersionTaggingAction,
action => action,
}
} else {
action
};
Action::S3Action(action)
}
async fn get_or_fetch_object_tag_conditions<T>(
req: &mut S3Request<T>,
store: &ECStore,
bucket: &str,
object: &str,
version_id: Option<&str>,
action: Action,
) -> S3Result<HashMap<String, Vec<String>>> {
if let Some(cached) = req.extensions.get::<ObjectTagConditions>()
&& cached.matches(bucket, object, version_id)
{
return Ok(cached.values.clone());
}
counter!("rustfs_object_tag_conditions_fetched_total", "op" => action_tag_metric_label(&action)).increment(1);
let fetched = FS::get_object_tag_conditions_for_policy_from_store(store, bucket, object, version_id).await?;
req.extensions
.insert(ObjectTagConditions::new(bucket, object, version_id, fetched.clone()));
Ok(fetched)
}
async fn maybe_merge_object_tag_conditions<T>(
req: &mut S3Request<T>,
action: Action,
bucket: &str,
object: &str,
version_id: Option<&str>,
conditions: &mut HashMap<String, Vec<String>>,
needs_tag: bool,
) -> S3Result<()> {
if !needs_tag || bucket.is_empty() || object.is_empty() {
counter!("rustfs_object_tag_conditions_skipped_total", "op" => action_tag_metric_label(&action)).increment(1);
return Ok(());
}
let store = request_object_store(req)?;
let tags = get_or_fetch_object_tag_conditions(req, store.as_ref(), bucket, object, version_id, action).await?;
merge_object_tag_conditions(conditions, &tags);
Ok(())
}
/// Returns true when the owner (root or parent=root credentials) may bypass bucket policy
/// explicit Deny for this action. Per AWS S3, only GetBucketPolicy, PutBucketPolicy, and
/// DeleteBucketPolicy have this bypass so the admin can recover from a misconfigured policy.
pub(crate) fn owner_can_bypass_policy_deny(is_owner: bool, action: &Action) -> bool {
is_owner
&& matches!(
action,
Action::S3Action(S3Action::GetBucketPolicyAction)
| Action::S3Action(S3Action::PutBucketPolicyAction)
| Action::S3Action(S3Action::DeleteBucketPolicyAction)
)
}
/// Context shared by every denial exit of [`authorize_request`] (issue #5740).
///
/// The wire response intentionally stays the bare "Access Denied" so no policy
/// detail leaks to clients; the structured server-side event emitted here is
/// the only place the denial reason is recorded. Probe-style callers that use
/// denial as an expected filter outcome (per-bucket ListBuckets fallback,
/// per-object metadata permission collection) set
/// [`ReqInfo::suppress_denial_log`] so their routine denials log at `debug`
/// instead of `warn`.
struct DenialContext<'a> {
quiet: bool,
bucket: &'a str,
object: &'a str,
version_id: Option<&'a str>,
account: Option<&'a str>,
is_owner: bool,
}
impl DenialContext<'_> {
fn log(&self, reason: &'static str, action: Action) {
if self.quiet {
tracing::debug!(
event = "s3_authorization_denied",
reason,
action = ?action,
bucket = %self.bucket,
object = %self.object,
version_id = ?self.version_id,
account = self.account.unwrap_or("<anonymous>"),
is_owner = self.is_owner,
"authorization probe denied"
);
return;
}
tracing::warn!(
event = "s3_authorization_denied",
reason,
action = ?action,
bucket = %self.bucket,
object = %self.object,
version_id = ?self.version_id,
account = self.account.unwrap_or("<anonymous>"),
is_owner = self.is_owner,
"request denied by authorization layer"
);
}
/// Build the generic AccessDenied response after recording the structured
/// denial event.
fn deny(&self, reason: &'static str, action: Action) -> S3Error {
self.log(reason, action);
s3_error!(AccessDenied, "Access Denied")
}
}
/// Authorizes the request based on the action and credentials.
pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3Result<()> {
let internal_object_authorization = req.extensions.get::<InternalObjectAuthorization>().is_some();
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
let req_info = req_info_ref(req)?;
let cred = req_info.cred.clone();
let is_owner = req_info.is_owner;
let bucket = req_info.bucket.clone().unwrap_or_default();
let object = req_info.object.clone().unwrap_or_default();
let version_id = req_info.version_id.clone();
let quiet_denial = req_info.suppress_denial_log;
let store = request_object_store(req)?;
let denial = DenialContext {
quiet: quiet_denial,
bucket: bucket.as_str(),
object: object.as_str(),
version_id: version_id.as_deref(),
account: cred.as_ref().map(|c| c.access_key.as_str()),
is_owner,
};
if let Some(cred) = &cred {
let iam_store = request_iam_store(req)?;
let default_claims = HashMap::new();
let claims = cred.claims.as_ref().unwrap_or(&default_claims);
let client_info = req.extensions.get::<ClientInfo>();
let mut conditions = authorization_conditions(req, cred, version_id.as_deref(), None, remote_addr, client_info, action)?;
let action_args = Args {
account: &cred.access_key,
groups: &cred.groups,
action,
bucket: bucket.as_str(),
conditions: &conditions,
is_owner,
object: object.as_str(),
claims,
deny_only: false,
};
let prepared = iam_store.prepare_auth(&action_args).await;
let needs_tag_from_iam = prepared.needs_existing_object_tag;
let bucket_tag_hint = if !bucket.is_empty() && !object.is_empty() {
Some(load_bucket_policy_existing_object_tag_hint(store.as_ref(), bucket.as_str(), action).await)
} else {
None
};
let needs_tag_from_bucket = if let Some(hint) = bucket_tag_hint.as_ref() {
let bucket_args = BucketPolicyArgs {
bucket: bucket.as_str(),
action,
is_owner,
account: cred.access_key.as_str(),
groups: &cred.groups,
conditions: &conditions,
object: object.as_str(),
};
bucket_policy_needs_existing_object_tag_from_hint(hint, &bucket_args).await
} else {
false
};
let needs_tag = needs_tag_from_iam || needs_tag_from_bucket;
if needs_tag {
tracing::debug!(
event = EVENT_OBJECT_TAG_AUTHORIZATION,
component = LOG_COMPONENT_ACCESS,
subsystem = LOG_SUBSYSTEM_AUTHORIZATION,
anonymous = false,
bucket = %bucket,
?action,
needs_tag_from_iam,
needs_tag_from_bucket,
"Object tag authorization conditions required"
);
}
maybe_merge_object_tag_conditions(
req,
action,
bucket.as_str(),
object.as_str(),
version_id.as_deref(),
&mut conditions,
needs_tag,
)
.await?;
let bucket_name = bucket.as_str();
// Per AWS S3: root can always perform GetBucketPolicy, PutBucketPolicy, DeleteBucketPolicy
// even if bucket policy explicitly denies. Other actions (ListBucket, GetObject, etc.) are
// subject to bucket policy Deny for root as well. See: repost.aws/knowledge-center/s3-accidentally-denied-access
// Here "owner" means root or credentials whose parent_user is root (e.g. Console admin via STS).
let owner_can_bypass_deny = owner_can_bypass_policy_deny(is_owner, &action);
if !bucket_name.is_empty()
&& !owner_can_bypass_deny
&& !PolicySys::try_is_allowed_for_store(
store.as_ref(),
&BucketPolicyArgs {
bucket: bucket_name,
action,
// Early explicit-deny gate for bucket policy: use owner short-circuit path so
// deny statements are enforced before IAM/bucket allow fallback evaluation.
is_owner: true,
account: &cred.access_key,
groups: &cred.groups,
conditions: &conditions,
object: object.as_str(),
},
)
.await
.map_err(ApiError::from)?
{
return Err(denial.deny("bucket_policy_explicit_deny", action));
}
let iam_allowed = {
let mut final_args = Args {
account: &cred.access_key,
groups: &cred.groups,
action,
bucket: bucket.as_str(),
conditions: &conditions,
is_owner,
object: object.as_str(),
claims,
deny_only: false,
};
let allowed = iam_store
.try_eval_prepared(&prepared, &final_args)
.await
.map_err(ApiError::from)?;
if !allowed
&& matches!(
action,
Action::S3Action(
S3Action::DeleteObjectAction
| S3Action::DeleteObjectVersionAction
| S3Action::ForceDeleteBucketAction
| S3Action::ForceDeleteObjectAction
| S3Action::ListBucketVersionsAction
| S3Action::BypassGovernanceRetentionAction
| S3Action::ReplicateDeleteAction
)
)
&& prepared.combined_policy_for_view().is_some()
{
// Bucket policy Allow may supplement an implicit IAM denial,
// but must not override an explicit deletion-policy Deny.
final_args.deny_only = true;
if !iam_store
.try_eval_prepared(&prepared, &final_args)
.await
.map_err(ApiError::from)?
{
return Err(denial.deny("iam_explicit_deny", action));
}
}
allowed
};
if iam_allowed {
if !internal_object_authorization {
authorize_table_data_plane_if_needed(req, action, bucket.as_str(), object.as_str(), cred, is_owner, &conditions)
.await?;
}
return Ok(());
}
if action == Action::S3Action(S3Action::ListAllMyBucketsAction) {
denial.log("iam_implicit_deny", action);
return Err(ApiError::access_denied().into());
}
let policy_allowed_fallback = PolicySys::try_is_allowed_for_store(
store.as_ref(),
&BucketPolicyArgs {
bucket: bucket.as_str(),
action,
is_owner,
account: &cred.access_key,
groups: &cred.groups,
conditions: &conditions,
object: object.as_str(),
},
)
.await
.map_err(ApiError::from)?;
if policy_allowed_fallback {
if !internal_object_authorization {
authorize_table_data_plane_if_needed(req, action, bucket.as_str(), object.as_str(), cred, is_owner, &conditions)
.await?;
}
return Ok(());
}
} else {
let default_cred = rustfs_credentials::Credentials::default();
let client_info = req.extensions.get::<ClientInfo>();
let mut conditions = authorization_conditions(
req,
&default_cred,
version_id.as_deref(),
req.region.clone(),
remote_addr,
client_info,
action,
)?;
let no_groups: Option<Vec<String>> = None;
let bucket_tag_hint = if !bucket.is_empty() && !object.is_empty() {
Some(load_bucket_policy_existing_object_tag_hint(store.as_ref(), bucket.as_str(), action).await)
} else {
None
};
let needs_tag_from_bucket = if let Some(hint) = bucket_tag_hint.as_ref() {
let bucket_args = BucketPolicyArgs {
bucket: bucket.as_str(),
action,
is_owner: false,
account: "",
groups: &no_groups,
conditions: &conditions,
object: object.as_str(),
};
bucket_policy_needs_existing_object_tag_from_hint(hint, &bucket_args).await
} else {
false
};
if needs_tag_from_bucket {
tracing::debug!(
event = EVENT_OBJECT_TAG_AUTHORIZATION,
component = LOG_COMPONENT_ACCESS,
subsystem = LOG_SUBSYSTEM_AUTHORIZATION,
anonymous = true,
bucket = %bucket,
?action,
"Object tag authorization conditions required"
);
}
maybe_merge_object_tag_conditions(
req,
action,
bucket.as_str(),
object.as_str(),
version_id.as_deref(),
&mut conditions,
needs_tag_from_bucket,
)
.await?;
let bucket_name = bucket.as_str();
if !bucket_name.is_empty()
&& !PolicySys::try_is_allowed_for_store(
store.as_ref(),
&BucketPolicyArgs {
bucket: bucket_name,
action,
// Early explicit-deny gate for bucket policy in anonymous path.
is_owner: true,
account: "",
groups: &None,
conditions: &conditions,
object: object.as_str(),
},
)
.await
.map_err(ApiError::from)?
{
return Err(denial.deny("bucket_policy_explicit_deny", action));
}
if action != Action::S3Action(S3Action::ListAllMyBucketsAction) {
let policy_allowed = PolicySys::try_is_allowed_for_store(
store.as_ref(),
&BucketPolicyArgs {
bucket: bucket.as_str(),
action,
is_owner: false,
account: "",
groups: &None,
conditions: &conditions,
object: object.as_str(),
},
)
.await
.map_err(ApiError::from)?;
// A bucket policy granting s3:ListBucket also covers listing versions.
// Keep this compatibility fallback inside the same public-access gate.
let policy_allowed = policy_allowed
|| (action == Action::S3Action(S3Action::ListBucketVersionsAction)
&& PolicySys::try_is_allowed_for_store(
store.as_ref(),
&BucketPolicyArgs {
bucket: bucket.as_str(),
action: Action::S3Action(S3Action::ListBucketAction),
is_owner: false,
account: "",
groups: &None,
conditions: &conditions,
object: "",
},
)
.await
.map_err(ApiError::from)?);
if policy_allowed {
deny_anonymous_table_data_plane_if_needed(req, action, bucket.as_str(), object.as_str()).await?;
// RestrictPublicBuckets: when true, deny public access even if bucket policy allows it.
match store.restricts_public_bucket_access(bucket_name).await {
Ok(true) => return Err(denial.deny("restrict_public_buckets", action)),
Ok(false) => {}
Err(StorageError::ConfigNotFound) => {}
Err(_) => {
return Err(denial.deny("public_access_block_unavailable", action));
}
}
return Ok(());
}
}
}
Err(denial.deny("no_policy_allows_action", action))
}
// Multipart parts are staged and become visible only after CompleteMultipartUpload,
// which uses the normal publication-fenced authorization path.
async fn authorize_staged_multipart_part<T>(req: &mut S3Request<T>) -> S3Result<()> {
req.extensions.insert(StagedMultipartPartAuthorization);
let result = authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await;
req.extensions.remove::<StagedMultipartPartAuthorization>();
result
}
/// Check if the request has the x-amz-bypass-governance-retention header set to true
pub fn has_bypass_governance_header(headers: &http::HeaderMap) -> bool {
headers
.get(AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE)
.and_then(|v| v.to_str().ok())
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
fn legal_hold_write_requested(object_lock_legal_hold_status: Option<&ObjectLockLegalHoldStatus>) -> bool {
object_lock_legal_hold_status.is_some()
}
fn retention_write_requested(
object_lock_mode: Option<&ObjectLockMode>,
object_lock_retain_until_date: Option<&Timestamp>,
) -> bool {
object_lock_mode.is_some() || object_lock_retain_until_date.is_some()
}
fn get_bucket_policy_authorize_action() -> Action {
Action::S3Action(S3Action::GetBucketPolicyAction)
}
fn put_bucket_policy_authorize_action() -> Action {
Action::S3Action(S3Action::PutBucketPolicyAction)
}
/// Both website-config handlers authorize through this one function so the
/// write side and the delete side cannot drift apart. RustFS has no dedicated
/// `s3:PutBucketWebsite` / `s3:DeleteBucketWebsite` action, so the bucket-config
/// mutation convention applies (same as `put_bucket_request_payment` and
/// `put_bucket_accelerate_configuration`).
fn bucket_website_config_authorize_action() -> Action {
Action::S3Action(S3Action::PutBucketPolicyAction)
}
fn post_object_authorize_action() -> Action {
Action::S3Action(S3Action::PutObjectAction)
}
fn complete_multipart_upload_authorize_action() -> Action {
Action::S3Action(S3Action::PutObjectAction)
}
fn list_parts_authorize_action() -> Action {
Action::S3Action(S3Action::ListMultipartUploadPartsAction)
}
fn table_data_plane_admin_action(action: Action) -> Option<AdminAction> {
match action {
Action::S3Action(
S3Action::GetObjectAction
| S3Action::GetObjectAclAction
| S3Action::GetObjectVersionAction
| S3Action::GetObjectAttributesAction
| S3Action::GetObjectVersionAttributesAction
| S3Action::GetObjectTaggingAction
| S3Action::GetObjectVersionTaggingAction
| S3Action::GetObjectRetentionAction
| S3Action::GetObjectLegalHoldAction
| S3Action::GetObjectVersionForReplicationAction,
) => Some(AdminAction::GetTableMetadataAction),
Action::S3Action(
S3Action::PutObjectAction
| S3Action::PutObjectAclAction
| S3Action::DeleteObjectAction
| S3Action::DeleteObjectVersionAction
| S3Action::PutObjectTaggingAction
| S3Action::PutObjectVersionTaggingAction
| S3Action::DeleteObjectTaggingAction
| S3Action::DeleteObjectVersionTaggingAction
| S3Action::PutObjectRetentionAction
| S3Action::PutObjectLegalHoldAction
| S3Action::BypassGovernanceRetentionAction
| S3Action::AbortMultipartUploadAction
| S3Action::ListMultipartUploadPartsAction
| S3Action::RestoreObjectAction
| S3Action::ReplicateObjectAction
| S3Action::ReplicateDeleteAction
| S3Action::ReplicateTagsAction
| S3Action::PutObjectFanOutAction,
) => Some(AdminAction::SetTableMetadataAction),
_ => None,
}
}
fn table_data_plane_content_mutation(action: Action) -> bool {
matches!(
action,
Action::S3Action(
S3Action::PutObjectAction
| S3Action::DeleteObjectAction
| S3Action::DeleteObjectVersionAction
| S3Action::RestoreObjectAction
| S3Action::ReplicateObjectAction
| S3Action::ReplicateDeleteAction
| S3Action::PutObjectFanOutAction
)
)
}
fn table_data_plane_publication_fence_required<T>(req: &S3Request<T>, action: Action) -> bool {
req.extensions.get::<StagedMultipartPartAuthorization>().is_none() && table_data_plane_content_mutation(action)
}
fn table_catalog_backend_for_data_plane<T>(
req: &S3Request<T>,
) -> S3Result<crate::table_catalog::EcStoreTableCatalogObjectBackend<ECStore>> {
let context = match req.extensions.get::<Arc<ServerContextSlot>>() {
Some(server_ctx) => server_ctx.installed_app_context(),
None => runtime_sources::current_app_context(),
}
.ok_or_else(object_store_not_initialized_error)?;
Ok(crate::table_catalog::EcStoreTableCatalogObjectBackend::new_with_strong_runtime(
context.object_store(),
context.table_catalog_strong_runtime(),
))
}
fn table_catalog_store_for_data_plane<T>(
req: &S3Request<T>,
) -> S3Result<crate::table_catalog::EcStoreTableCatalogStore<ECStore>> {
let backend = table_catalog_backend_for_data_plane(req)?;
crate::table_catalog::ConfiguredTableCatalogStore::from_env(backend)
.map_err(|err| s3_error!(InternalError, "failed to configure table catalog backing: {}", err))
}
fn table_publication_guard_error(err: crate::table_catalog::TableCatalogStoreError) -> S3Error {
let code = match &err {
crate::table_catalog::TableCatalogStoreError::Unavailable(_) => S3ErrorCode::ServiceUnavailable,
_ => S3ErrorCode::InternalError,
};
S3Error::with_message(code, format!("failed to acquire table publication guard: {err}"))
}
async fn retain_table_data_plane_publication_guard<T>(
req: &mut S3Request<T>,
table_bucket: &str,
lock_object: &str,
) -> S3Result<()> {
let key = (table_bucket.to_string(), lock_object.to_string());
let retained = req
.extensions
.get::<TableDataPlanePublicationGuards>()
.cloned()
.unwrap_or_default();
if retained.state.lock().keys.contains(&key) {
return Ok(());
}
let backend = table_catalog_backend_for_data_plane(req)?;
let guard = crate::table_catalog::TableCatalogObjectBackend::acquire_read_lock(&backend, table_bucket, lock_object)
.await
.map_err(table_publication_guard_error)?;
let lock_lost_signal = guard.lock_lost_signal();
let mut state = retained.state.lock();
state.keys.insert(key);
state.guards.push(Box::new(guard));
if let Some(signal) = lock_lost_signal {
state.lock_lost_signals.push(signal);
}
drop(state);
req.extensions.insert(retained);
Ok(())
}
async fn retain_table_bucket_publication_guard<T>(req: &mut S3Request<T>, table_bucket: &str) -> S3Result<()> {
retain_table_data_plane_publication_guard(
req,
table_bucket,
&crate::table_catalog::default_table_bucket_publication_lock_path(),
)
.await
}
async fn retain_table_publication_guard<T>(
req: &mut S3Request<T>,
resource: &crate::table_catalog::TableDataPlaneResource,
) -> S3Result<()> {
let namespace = crate::table_catalog::Namespace::parse(&resource.namespace)
.map_err(|err| s3_error!(InternalError, "persisted table namespace is invalid: {}", err))?;
let table = crate::table_catalog::IdentifierSegment::parse(resource.table.clone())
.map_err(|err| s3_error!(InternalError, "persisted table name is invalid: {}", err))?;
retain_table_data_plane_publication_guard(
req,
&resource.table_bucket,
&crate::table_catalog::default_table_publication_lock_path(&namespace, &table),
)
.await?;
let retained = req
.extensions
.get::<TableDataPlanePublicationGuards>()
.cloned()
.ok_or_else(|| s3_error!(InternalError, "table publication guard state is missing"))?;
retained.state.lock().resources.insert(
(resource.table_bucket.clone(), resource.warehouse_object_prefix.clone()),
resource.clone(),
);
Ok(())
}
async fn table_bucket_enabled_for_data_plane<T>(req: &S3Request<T>, bucket: &str) -> S3Result<bool> {
if bucket.is_empty() {
return Ok(false);
}
match request_object_store(req)?.table_bucket_enabled(bucket).await {
Ok(table_bucket_enabled) => Ok(table_bucket_enabled),
Err(err) if is_err_bucket_not_found(&err) => Ok(false),
Err(err) => {
tracing::warn!(
bucket = %bucket,
error = %err,
"failed to load bucket metadata while authorizing table data-plane access"
);
Err(s3_error!(AccessDenied, "Access Denied"))
}
}
}
fn update_table_list_fingerprint_field(hasher: &mut Sha256, value: &[u8]) {
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value);
}
fn table_list_principal_fingerprint(cred: Option<&rustfs_credentials::Credentials>, is_owner: bool) -> String {
let mut hasher = Sha256::new();
update_table_list_fingerprint_field(
&mut hasher,
cred.map_or(b"anonymous".as_slice(), |credential| credential.access_key.as_bytes()),
);
hasher.update([u8::from(is_owner)]);
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
}
fn table_list_snapshot_fingerprint(resources: &[TableDataPlaneListResource]) -> String {
let mut hasher = Sha256::new();
for entry in resources {
for value in [
entry.resource.table_bucket.as_bytes(),
entry.resource.namespace.as_bytes(),
entry.resource.table.as_bytes(),
entry.resource.table_id.as_bytes(),
entry.resource.warehouse_object_prefix.as_bytes(),
] {
update_table_list_fingerprint_field(&mut hasher, value);
}
hasher.update([u8::from(entry.allowed)]);
}
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
}
fn table_list_cursor_key<T>(req: &S3Request<T>) -> S3Result<[u8; 32]> {
let credentials = match req.extensions.get::<Arc<ServerContextSlot>>() {
Some(server_ctx) => server_ctx
.installed_app_context()
.and_then(|context| context.action_credentials().get()),
None => runtime_sources::current_action_credentials(),
}
.ok_or_else(|| table_data_plane_list_internal_error("action credentials are not initialized"))?;
let mut hasher = Sha256::new();
update_table_list_fingerprint_field(&mut hasher, TABLE_DATA_PLANE_LIST_CURSOR_KEY_CONTEXT);
update_table_list_fingerprint_field(&mut hasher, credentials.access_key.as_bytes());
update_table_list_fingerprint_field(&mut hasher, credentials.secret_key.as_bytes());
Ok(hasher.finalize().into())
}
fn table_list_catalog_error(err: crate::table_catalog::TableCatalogStoreError) -> S3Error {
if matches!(err, crate::table_catalog::TableCatalogStoreError::Unavailable(_)) {
S3Error::from(ApiError::service_unavailable())
} else {
S3Error::from(ApiError::access_denied())
}
}
async fn prepare_table_data_plane_list_access<T>(req: &mut S3Request<T>, bucket: &str, action: S3Action) -> S3Result<()> {
if !table_bucket_enabled_for_data_plane(req, bucket).await? {
return Ok(());
}
retain_table_bucket_publication_guard(req, bucket).await?;
if !table_bucket_enabled_for_data_plane(req, bucket).await? {
return Err(S3Error::from(ApiError::access_denied()));
}
let (cred, is_owner) = {
let req_info = req_info_ref(req)?;
(req_info.cred.clone(), req_info.is_owner)
};
let remote_addr = req
.extensions
.get::<Option<RemoteAddr>>()
.and_then(|value| value.map(|address| address.0));
let client_info = req.extensions.get::<ClientInfo>();
let default_cred = rustfs_credentials::Credentials::default();
let condition_cred = cred.as_ref().unwrap_or(&default_cred);
let conditions = authorization_conditions(
req,
condition_cred,
None,
req.region.clone(),
remote_addr,
client_info,
Action::S3Action(action),
)?;
let iam_store = cred.as_ref().map(|_| request_iam_store(req)).transpose()?;
let store = table_catalog_store_for_data_plane(req)?;
let tables = crate::table_catalog::TableCatalogStore::list_all_tables(&store, bucket)
.await
.map_err(table_list_catalog_error)?;
let default_claims = HashMap::new();
let claims = cred
.as_ref()
.and_then(|credential| credential.claims.as_ref())
.unwrap_or(&default_claims);
let mut resources = Vec::with_capacity(tables.len());
for table in tables {
let warehouse_object_prefix =
crate::table_catalog::table_warehouse_object_prefix(&table).map_err(table_list_catalog_error)?;
let resource = crate::table_catalog::table_data_plane_resource_from_entry(table.clone(), warehouse_object_prefix);
let allowed = match (cred.as_ref(), iam_store.as_ref()) {
(Some(credential), Some(iam_store)) => {
let resource_object = resource.catalog_resource_object();
iam_store
.is_allowed(&Args {
account: &credential.access_key,
groups: &credential.groups,
action: Action::AdminAction(AdminAction::GetTableMetadataAction),
bucket,
conditions: &conditions,
is_owner,
object: &resource_object,
claims,
deny_only: false,
})
.await
}
_ => false,
};
resources.push(TableDataPlaneListResource { resource, allowed });
}
resources.sort_by(|left, right| {
left.resource
.warehouse_object_prefix
.cmp(&right.resource.warehouse_object_prefix)
});
if resources.windows(2).any(|window| {
crate::table_catalog::warehouse_object_prefixes_overlap(
&window[0].resource.warehouse_object_prefix,
&window[1].resource.warehouse_object_prefix,
)
}) {
return Err(S3Error::from(ApiError::access_denied()));
}
let access = TableDataPlaneListAccess {
bucket: bucket.to_string(),
principal_fingerprint: table_list_principal_fingerprint(cred.as_ref(), is_owner),
snapshot_fingerprint: table_list_snapshot_fingerprint(&resources),
cursor_key: table_list_cursor_key(req)?,
resources,
};
req.extensions.insert(access);
Ok(())
}
async fn table_data_plane_resource_for_request<T>(
req: &mut S3Request<T>,
bucket: &str,
object: &str,
table_bucket_enabled: bool,
) -> S3Result<Option<crate::table_catalog::TableDataPlaneResource>> {
if !table_bucket_enabled || bucket.is_empty() || object.is_empty() {
return Ok(None);
}
let catalog_metadata = crate::table_catalog::is_reserved_table_object_key(object)
&& crate::table_catalog::table_identity_from_metadata_object_key(object).is_some();
let key = (bucket.to_string(), object.to_string());
let retained = req
.extensions
.get::<TableDataPlanePublicationGuards>()
.cloned()
.unwrap_or_default();
{
let state = retained.state.lock();
if state.missing_resources.contains(&key) {
return Ok(None);
}
if !catalog_metadata
&& let Some(resource) = state
.resources
.values()
.find(|resource| resource.table_bucket == bucket && object.starts_with(&resource.warehouse_object_prefix))
.cloned()
{
return Ok(Some(resource));
}
}
let store = table_catalog_store_for_data_plane(req)?;
let resource = if catalog_metadata {
crate::table_catalog::table_metadata_data_plane_resource_for_object(&store, bucket, object).await
} else {
crate::table_catalog::table_data_plane_resource_for_object(&store, bucket, object).await
}
.map_err(|err| {
tracing::warn!(
bucket = %bucket,
object = %object,
error = %err,
"failed to resolve table data-plane resource"
);
if matches!(err, crate::table_catalog::TableCatalogStoreError::Unavailable(_)) {
S3Error::from(ApiError::service_unavailable())
} else {
s3_error!(AccessDenied, "Access Denied")
}
})?;
let resource = require_owned_reserved_table_object(object, resource)?;
let bucket_fence_key = (bucket.to_string(), crate::table_catalog::default_table_bucket_publication_lock_path());
let mut state = retained.state.lock();
if resource.is_none() && state.keys.contains(&bucket_fence_key) {
state.missing_resources.insert(key);
drop(state);
req.extensions.insert(retained);
}
Ok(resource)
}
fn require_owned_reserved_table_object<T>(object: &str, resource: Option<T>) -> S3Result<Option<T>> {
if crate::table_catalog::is_reserved_table_object_key(object) && resource.is_none() {
return Err(S3Error::from(ApiError::access_denied()));
}
Ok(resource)
}
async fn table_data_plane_resource_for_authorization<T>(
req: &mut S3Request<T>,
bucket: &str,
object: &str,
table_bucket_enabled: bool,
) -> S3Result<Option<crate::table_catalog::TableDataPlaneResource>> {
table_data_plane_resource_for_request(req, bucket, object, table_bucket_enabled).await
}
async fn authorize_table_data_plane_if_needed<T>(
req: &mut S3Request<T>,
action: Action,
bucket: &str,
object: &str,
cred: &rustfs_credentials::Credentials,
is_owner: bool,
conditions: &HashMap<String, Vec<String>>,
) -> S3Result<()> {
let Some(admin_action) = table_data_plane_admin_action(action) else {
return Ok(());
};
if table_data_plane_publication_fence_required(req, action) {
retain_table_bucket_publication_guard(req, bucket).await?;
}
let table_bucket_enabled = table_bucket_enabled_for_data_plane(req, bucket).await?;
let Some(resource) = table_data_plane_resource_for_authorization(req, bucket, object, table_bucket_enabled).await? else {
return Ok(());
};
let iam_store = request_iam_store(req)?;
let default_claims = HashMap::new();
let claims = cred.claims.as_ref().unwrap_or(&default_claims);
let resource_object = resource.catalog_resource_object();
let allowed = iam_store
.is_allowed(&Args {
account: &cred.access_key,
groups: &cred.groups,
action: Action::AdminAction(admin_action),
bucket: resource.table_bucket.as_str(),
conditions,
is_owner,
object: resource_object.as_str(),
claims,
deny_only: false,
})
.await;
if allowed {
if table_data_plane_publication_fence_required(req, action) {
retain_table_publication_guard(req, &resource).await?;
}
return Ok(());
}
tracing::debug!(
bucket = %bucket,
object = %object,
table_bucket = %resource.table_bucket,
table_namespace = %resource.namespace,
table = %resource.table,
table_id = %resource.table_id,
?admin_action,
"table data-plane access denied by table resource policy"
);
Err(s3_error!(AccessDenied, "Access Denied"))
}
/// Authorizes one exact object key after its enclosing table-catalog operation has been authorized.
pub(crate) async fn authorize_internal_object_request<T>(req: &mut S3Request<T>, action: Action) -> S3Result<()> {
req.extensions.insert(InternalObjectAuthorization);
let result = authorize_request(req, action).await;
req.extensions.remove::<InternalObjectAuthorization>();
result
}
async fn deny_anonymous_table_data_plane_if_needed<T>(
req: &mut S3Request<T>,
action: Action,
bucket: &str,
object: &str,
) -> S3Result<()> {
if table_data_plane_admin_action(action).is_none() {
return Ok(());
}
if table_data_plane_publication_fence_required(req, action) {
retain_table_bucket_publication_guard(req, bucket).await?;
}
let table_bucket_enabled = table_bucket_enabled_for_data_plane(req, bucket).await?;
if table_data_plane_resource_for_authorization(req, bucket, object, table_bucket_enabled)
.await?
.is_some()
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
Ok(())
}
fn validate_post_object_success_controls(input: &PostObjectInput) -> S3Result<()> {
if let Some(status) = input.success_action_status
&& !matches!(status, 200 | 201 | 204)
{
return Err(s3_error!(MalformedPOSTRequest, "success_action_status must be one of 200, 201, or 204"));
}
if let Some(redirect) = input.success_action_redirect.as_deref().map(str::trim)
&& !redirect.is_empty()
&& Url::parse(redirect).is_err()
{
return Err(s3_error!(MalformedPOSTRequest, "success_action_redirect must be a valid absolute URL"));
}
Ok(())
}
#[async_trait::async_trait]
impl S3Access for FS {
async fn check(&self, cx: &mut S3AccessContext<'_>) -> S3Result<()> {
// SigV4 only authorises the request properties covered by headers it
// signed. Reject unsigned `x-amz-*` headers first, before the session
// token lookup below or any handler reads a request header.
reject_unsigned_amz_headers_on_sigv4_request(cx.headers(), cx.uri().query())?;
// Upper layer has verified ak/sk
// info!(
// "s3 check uri: {:?}, method: {:?} path: {:?}, s3_op: {:?}, cred: {:?}, headers:{:?}",
// cx.uri(),
// cx.method(),
// cx.s3_path(),
// cx.s3_op().name(),
// cx.credentials(),
// cx.headers(),
// // cx.extensions_mut(),
// );
// Resolve auth against this server's context (backlog#1052 S6) so a
// second embedded server validates keys against its own root identity
// and IAM domain; the slot resolves the process default when it has
// not been installed, keeping single-instance behavior unchanged.
let app_context = self.server_ctx().app_context();
let (cred, is_owner) = if let Some(input_cred) = cx.credentials() {
let (cred, is_owner) = check_key_valid_with_context(
get_session_token(cx.uri(), cx.headers()).unwrap_or_default(),
&input_cred.access_key,
app_context.as_deref(),
)
.await?;
(Some(cred), is_owner)
} else {
(None, false)
};
let request_context = cx.extensions_mut().get::<RequestContext>().cloned();
let region = app_context
.as_deref()
.and_then(|context| context.region().get())
.or_else(runtime_sources::current_region);
let req_info = ReqInfo {
cred,
is_owner,
region,
request_context,
..Default::default()
};
let auth_type = get_request_auth_type_with_query(cx.headers(), cx.uri().query());
let verified_presigned = matches!(auth_type, AuthType::Presigned);
let verified_sigv4 = matches!(auth_type, AuthType::Presigned | AuthType::Signed);
// Publish this server's context slot so downstream data-plane handlers
// resolve the same store (backlog#1052 S6).
{
let ext = cx.extensions_mut();
ext.insert(self.server_ctx().clone());
ext.insert(req_info);
if verified_presigned {
ext.insert(VerifiedPresignedRequest);
}
if verified_sigv4 {
ext.insert(VerifiedSigV4Request);
}
}
// The size capability is intentionally scoped to the single-object
// PutObject operation. Validate this at the operation-aware access
// boundary so unsupported GET/HEAD/DELETE/bucket routes cannot silently
// ignore a signed capability query.
if parse_presigned_put_max_content_length(cx.headers(), cx.uri().query(), verified_presigned)?.is_some()
&& cx.s3_op().name() != "PutObject"
{
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} is only supported for presigned PutObject"),
));
}
if parse_presigned_multipart_max_total_object_size(cx.headers(), cx.uri().query(), verified_sigv4)?.is_some()
&& cx.s3_op().name() != "CreateMultipartUpload"
{
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("{RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY} is only supported for CreateMultipartUpload"),
));
}
license_check().map_err(|er| match er.kind() {
std::io::ErrorKind::PermissionDenied => s3_error!(AccessDenied, "{er}"),
_ => {
tracing::error!("license check failed due to unexpected error: {er}");
s3_error!(InternalError, "License validation failed")
}
})?;
// Verify uniformly here? Or verify separately below?
Ok(())
}
/// Checks whether the CreateBucket request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn create_bucket(&self, req: &mut S3Request<CreateBucketInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::CreateBucketAction)).await?;
if req.input.object_lock_enabled_for_bucket.is_some_and(|v| v) {
authorize_request(req, Action::S3Action(S3Action::PutBucketObjectLockConfigurationAction)).await?;
authorize_request(req, Action::S3Action(S3Action::PutBucketVersioningAction)).await?;
}
Ok(())
}
/// Checks whether the AbortMultipartUpload request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn abort_multipart_upload(&self, req: &mut S3Request<AbortMultipartUploadInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, Action::S3Action(S3Action::AbortMultipartUploadAction)).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the CompleteMultipartUpload request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn complete_multipart_upload(&self, req: &mut S3Request<CompleteMultipartUploadInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, complete_multipart_upload_authorize_action()).await?;
authorize_replication_only_put_headers(req).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the CopyObject request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn copy_object(&self, req: &mut S3Request<CopyObjectInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let (src_bucket, src_key, version_id) = match &req.input.copy_source {
CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Bucket { bucket, key, version_id } => {
(bucket.to_string(), key.to_string(), version_id.as_ref().map(|v| v.to_string()))
}
};
let source_bucket_generation = load_copy_source_bucket_generation(self, &src_bucket).await;
{
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(src_bucket.clone());
req_info.object = Some(src_key.clone());
req_info.version_id = version_id.clone();
// GHSA-3ppv: a versioned copy source must authorize against
// s3:GetObjectVersion, not s3:GetObject.
authorize_request(req, versioned_read_action(version_id.as_deref())).await?;
}
req.extensions.insert(source_bucket_generation?);
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
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?;
if legal_hold_write_requested(req.input.object_lock_legal_hold_status.as_ref()) {
authorize_request(req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?;
}
if retention_write_requested(req.input.object_lock_mode.as_ref(), req.input.object_lock_retain_until_date.as_ref()) {
authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?;
}
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the CreateMultipartUpload request has accesses to the resources.
async fn create_multipart_upload(&self, req: &mut S3Request<CreateMultipartUploadInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
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?;
if legal_hold_write_requested(req.input.object_lock_legal_hold_status.as_ref()) {
authorize_request(req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?;
}
if retention_write_requested(req.input.object_lock_mode.as_ref(), req.input.object_lock_retain_until_date.as_ref()) {
authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?;
}
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the DeleteBucket request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket(&self, req: &mut S3Request<DeleteBucketInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::DeleteBucketAction)).await?;
// MinIO evaluates s3:ForceDeleteBucket whenever the header is present,
// including the value `false`. Only a parsed `true` later skips the
// emptiness check. `s3:*` does not grant this action.
if force_delete_header(&req.headers)
.map_err(|_| invalid_force_delete_header())?
.is_some()
{
authorize_request(req, Action::S3Action(S3Action::ForceDeleteBucketAction)).await?;
}
Ok(())
}
/// Checks whether the DeleteBucketAnalyticsConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_analytics_configuration(
&self,
_req: &mut S3Request<DeleteBucketAnalyticsConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the DeleteBucketCors request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_cors(&self, req: &mut S3Request<DeleteBucketCorsInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::DeleteBucketCorsAction)).await
}
/// Checks whether the DeleteBucketEncryption request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_encryption(&self, req: &mut S3Request<DeleteBucketEncryptionInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketEncryptionAction)).await
}
/// Checks whether the DeleteBucketIntelligentTieringConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_intelligent_tiering_configuration(
&self,
_req: &mut S3Request<DeleteBucketIntelligentTieringConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the DeleteBucketInventoryConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_inventory_configuration(
&self,
_req: &mut S3Request<DeleteBucketInventoryConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the DeleteBucketLifecycle request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_lifecycle(&self, req: &mut S3Request<DeleteBucketLifecycleInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketLifecycleAction)).await
}
/// Checks whether the DeleteBucketMetricsConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_metrics_configuration(
&self,
_req: &mut S3Request<DeleteBucketMetricsConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the DeleteBucketOwnershipControls request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_ownership_controls(&self, _req: &mut S3Request<DeleteBucketOwnershipControlsInput>) -> S3Result<()> {
Ok(())
}
/// Checks whether the DeleteBucketPolicy request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_policy(&self, req: &mut S3Request<DeleteBucketPolicyInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::DeleteBucketPolicyAction)).await
}
/// Checks whether the DeleteBucketReplication request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_replication(&self, req: &mut S3Request<DeleteBucketReplicationInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutReplicationConfigurationAction)).await
}
/// Checks whether the DeleteBucketTagging request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_tagging(&self, req: &mut S3Request<DeleteBucketTaggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketTaggingAction)).await
}
/// Checks whether the DeleteBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_website(&self, req: &mut S3Request<DeleteBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, bucket_website_config_authorize_action()).await
}
/// Checks whether the DeleteObject request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_object(&self, req: &mut S3Request<DeleteObjectInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
#[cfg(test)]
wait_for_object_generation_test_hook(&bucket).await;
// Preserve DeleteObject's established NoSuchBucket response instead of
// letting policy lookup turn a missing bucket into AccessDenied.
if let Err(err) = &bucket_generation
&& err.code() == &S3ErrorCode::NoSuchBucket
{
return Err(S3Error::with_message(
S3ErrorCode::NoSuchBucket,
err.message().unwrap_or("The specified bucket does not exist").to_string(),
));
}
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
let authenticated = req_info.is_owner || req_info.cred.is_some();
let action = delete_object_authorize_action(req_info.version_id.as_deref());
authorize_request(req, action).await?;
let force_delete = match force_delete_header(&req.headers) {
Ok(value) => value.unwrap_or(false),
Err(_) => return Err(invalid_force_delete_header()),
};
let replica_request = req
.headers
.get(AMZ_BUCKET_REPLICATION_STATUS)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == ReplicationStatusType::Replica.as_str());
// The REPLICA header is caller-supplied, so it must not skip this
// check. s3:ReplicateDelete is still required separately for replica
// deletes; s3:* does not grant s3:ForceDeleteObject.
if force_delete {
authorize_request(req, Action::S3Action(S3Action::ForceDeleteObjectAction)).await?;
}
if !recursive_force_delete_has_authenticated_caller(&req.headers, authenticated, replica_request) {
return Err(s3_error!(AccessDenied, "Recursive force-delete requires an authenticated caller"));
}
// S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission
if has_bypass_governance_header(&req.headers) {
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
}
req.extensions
.insert(PendingDeleteBucketGenerationGuard::from_result(bucket_generation));
Ok(())
}
/// Checks whether the DeleteObjectTagging request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_object_tagging(&self, req: &mut S3Request<DeleteObjectTaggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(
req,
versioned_tagging_action(S3Action::DeleteObjectTaggingAction, req.input.version_id.as_deref()),
)
.await
}
/// Checks whether the DeleteObjects request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_objects(&self, req: &mut S3Request<DeleteObjectsInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = None;
req_info.version_id = None;
Ok(())
}
/// Checks whether the DeletePublicAccessBlock request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_public_access_block(&self, req: &mut S3Request<DeletePublicAccessBlockInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::DeleteBucketPublicAccessBlockAction)).await
}
/// Checks whether the GetBucketAccelerateConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_accelerate_configuration(
&self,
req: &mut S3Request<GetBucketAccelerateConfigurationInput>,
) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetBucketAcl request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_acl(&self, req: &mut S3Request<GetBucketAclInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketAclAction)).await
}
/// Checks whether the GetBucketAnalyticsConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_analytics_configuration(
&self,
_req: &mut S3Request<GetBucketAnalyticsConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the GetBucketCors request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_cors(&self, req: &mut S3Request<GetBucketCorsInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketCorsAction)).await
}
/// Checks whether the GetBucketEncryption request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_encryption(&self, req: &mut S3Request<GetBucketEncryptionInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketEncryptionAction)).await
}
/// Checks whether the GetBucketIntelligentTieringConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_intelligent_tiering_configuration(
&self,
_req: &mut S3Request<GetBucketIntelligentTieringConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the GetBucketInventoryConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_inventory_configuration(
&self,
_req: &mut S3Request<GetBucketInventoryConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the GetBucketLifecycleConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_lifecycle_configuration(
&self,
req: &mut S3Request<GetBucketLifecycleConfigurationInput>,
) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketLifecycleAction)).await
}
/// Checks whether the GetBucketLocation request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_location(&self, req: &mut S3Request<GetBucketLocationInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketLocationAction)).await
}
/// Checks whether the GetBucketLogging request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_logging(&self, req: &mut S3Request<GetBucketLoggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketLoggingAction)).await
}
/// Checks whether the GetBucketMetricsConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_metrics_configuration(&self, _req: &mut S3Request<GetBucketMetricsConfigurationInput>) -> S3Result<()> {
Ok(())
}
/// Checks whether the GetBucketNotificationConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_notification_configuration(
&self,
req: &mut S3Request<GetBucketNotificationConfigurationInput>,
) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketNotificationAction)).await
}
/// Checks whether the GetBucketOwnershipControls request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_ownership_controls(&self, _req: &mut S3Request<GetBucketOwnershipControlsInput>) -> S3Result<()> {
Ok(())
}
/// Checks whether the GetBucketPolicy request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_policy(&self, req: &mut S3Request<GetBucketPolicyInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, get_bucket_policy_authorize_action()).await
}
/// Checks whether the GetBucketPolicyStatus request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_policy_status(&self, req: &mut S3Request<GetBucketPolicyStatusInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyStatusAction)).await
}
/// Checks whether the GetBucketReplication request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_replication(&self, req: &mut S3Request<GetBucketReplicationInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetReplicationConfigurationAction)).await
}
/// Checks whether the GetBucketRequestPayment request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_request_payment(&self, req: &mut S3Request<GetBucketRequestPaymentInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetBucketTagging request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_tagging(&self, req: &mut S3Request<GetBucketTaggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketTaggingAction)).await
}
/// Checks whether the GetBucketVersioning request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_versioning(&self, req: &mut S3Request<GetBucketVersioningInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketVersioningAction)).await
}
/// Checks whether the GetBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_website(&self, req: &mut S3Request<GetBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetObject request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object(&self, req: &mut S3Request<GetObjectInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let source_generation = capture_odm_read_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
// GHSA-3ppv: a versioned read (?versionId=...) must authorize against
// s3:GetObjectVersion, not s3:GetObject.
authorize_request(req, versioned_read_action(req.input.version_id.as_deref())).await?;
req.extensions.insert(source_generation);
Ok(())
}
/// Checks whether the GetObjectAcl request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object_acl(&self, req: &mut S3Request<GetObjectAclInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(req, Action::S3Action(S3Action::GetObjectAclAction)).await
}
/// Checks whether the GetObjectAttributes request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object_attributes(&self, req: &mut S3Request<GetObjectAttributesInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
if req.input.version_id.is_some() {
authorize_request(req, Action::S3Action(S3Action::GetObjectVersionAttributesAction)).await?;
authorize_request(req, Action::S3Action(S3Action::GetObjectVersionAction)).await?;
} else {
authorize_request(req, Action::S3Action(S3Action::GetObjectAttributesAction)).await?;
authorize_request(req, Action::S3Action(S3Action::GetObjectAction)).await?;
}
Ok(())
}
/// Checks whether the GetObjectLegalHold request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object_legal_hold(&self, req: &mut S3Request<GetObjectLegalHoldInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(req, Action::S3Action(S3Action::GetObjectLegalHoldAction)).await
}
/// Checks whether the GetObjectLockConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object_lock_configuration(&self, req: &mut S3Request<GetObjectLockConfigurationInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketObjectLockConfigurationAction)).await
}
/// Checks whether the GetObjectRetention request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object_retention(&self, req: &mut S3Request<GetObjectRetentionInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(req, Action::S3Action(S3Action::GetObjectRetentionAction)).await
}
/// Checks whether the GetObjectTagging request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object_tagging(&self, req: &mut S3Request<GetObjectTaggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(
req,
versioned_tagging_action(S3Action::GetObjectTaggingAction, req.input.version_id.as_deref()),
)
.await
}
/// Checks whether the GetObjectTorrent request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_object_torrent(&self, _req: &mut S3Request<GetObjectTorrentInput>) -> S3Result<()> {
Ok(())
}
/// Checks whether the GetPublicAccessBlock request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_public_access_block(&self, req: &mut S3Request<GetPublicAccessBlockInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPublicAccessBlockAction)).await
}
/// Checks whether the HeadBucket request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn head_bucket(&self, req: &mut S3Request<HeadBucketInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::ListBucketAction)).await
}
/// Checks whether the HeadObject request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn head_object(&self, req: &mut S3Request<HeadObjectInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let source_generation = capture_odm_read_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
// A replication convergence check HEADs the replica to compare
// etag/size/mtime. For SSE-C replicas the worker holds no customer key,
// so authorize it as a replication action and let the handler skip the
// SSE-C read validation.
if get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_CHECK).as_deref() == Some("true") {
authorize_request(req, Action::S3Action(S3Action::ReplicateObjectAction)).await?;
req_info_mut(req)?.replication_request_authorized = true;
req.extensions.insert(source_generation);
return Ok(());
}
authorize_request(req, versioned_read_action(req.input.version_id.as_deref())).await?;
req.extensions.insert(source_generation);
Ok(())
}
/// Checks whether the ListBucketAnalyticsConfigurations request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_bucket_analytics_configurations(
&self,
_req: &mut S3Request<ListBucketAnalyticsConfigurationsInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the ListBucketIntelligentTieringConfigurations request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_bucket_intelligent_tiering_configurations(
&self,
_req: &mut S3Request<ListBucketIntelligentTieringConfigurationsInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the ListBucketInventoryConfigurations request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_bucket_inventory_configurations(
&self,
_req: &mut S3Request<ListBucketInventoryConfigurationsInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the ListBucketMetricsConfigurations request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_bucket_metrics_configurations(
&self,
_req: &mut S3Request<ListBucketMetricsConfigurationsInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the ListBuckets request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_buckets(&self, _req: &mut S3Request<ListBucketsInput>) -> S3Result<()> {
// check inside
Ok(())
}
/// Checks whether the ListMultipartUploads request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_multipart_uploads(&self, req: &mut S3Request<ListMultipartUploadsInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::ListBucketMultipartUploadsAction)).await?;
prepare_table_data_plane_list_access(req, &bucket, S3Action::ListBucketMultipartUploadsAction).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the `ListObjectVersions` request is authorized for the requested bucket.
///
/// Returns `Ok(())` if the request is allowed, or an error if access is denied or another
/// authorization-related issue occurs.
async fn list_object_versions(&self, req: &mut S3Request<ListObjectVersionsInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::ListBucketVersionsAction)).await?;
prepare_table_data_plane_list_access(req, &bucket, S3Action::ListBucketVersionsAction).await
}
/// Checks whether the ListObjects request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_objects(&self, req: &mut S3Request<ListObjectsInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::ListBucketAction)).await?;
prepare_table_data_plane_list_access(req, &bucket, S3Action::ListBucketAction).await
}
/// Checks whether the ListObjectsV2 request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_objects_v2(&self, req: &mut S3Request<ListObjectsV2Input>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let source_generation = capture_odm_read_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::ListBucketAction)).await?;
prepare_table_data_plane_list_access(req, &bucket, S3Action::ListBucketAction).await?;
req.extensions.insert(source_generation);
Ok(())
}
/// Checks whether the ListParts request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_parts(&self, req: &mut S3Request<ListPartsInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, list_parts_authorize_action()).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the PostObject request has accesses to the resources.
async fn post_object(&self, req: &mut S3Request<PostObjectInput>) -> S3Result<()> {
validate_post_object_success_controls(&req.input)?;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
req.extensions.insert(PostObjectRequestMarker);
authorize_request(req, post_object_authorize_action()).await
}
/// Checks whether the PutBucketAccelerateConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_accelerate_configuration(
&self,
req: &mut S3Request<PutBucketAccelerateConfigurationInput>,
) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutBucketAcl request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_acl(&self, req: &mut S3Request<PutBucketAclInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::PutBucketAclAction)).await
}
/// Checks whether the PutBucketAnalyticsConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_analytics_configuration(
&self,
_req: &mut S3Request<PutBucketAnalyticsConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the PutBucketCors request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_cors(&self, req: &mut S3Request<PutBucketCorsInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketCorsAction)).await
}
/// Checks whether the PutBucketEncryption request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_encryption(&self, req: &mut S3Request<PutBucketEncryptionInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketEncryptionAction)).await
}
/// Checks whether the PutBucketIntelligentTieringConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_intelligent_tiering_configuration(
&self,
_req: &mut S3Request<PutBucketIntelligentTieringConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the PutBucketInventoryConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_inventory_configuration(
&self,
_req: &mut S3Request<PutBucketInventoryConfigurationInput>,
) -> S3Result<()> {
Ok(())
}
/// Checks whether the PutBucketLifecycleConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_lifecycle_configuration(
&self,
req: &mut S3Request<PutBucketLifecycleConfigurationInput>,
) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketLifecycleAction)).await
}
async fn put_bucket_logging(&self, req: &mut S3Request<PutBucketLoggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketLoggingAction)).await
}
/// Checks whether the PutBucketMetricsConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_metrics_configuration(&self, _req: &mut S3Request<PutBucketMetricsConfigurationInput>) -> S3Result<()> {
Ok(())
}
/// Checks whether the PutBucketNotificationConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_notification_configuration(
&self,
req: &mut S3Request<PutBucketNotificationConfigurationInput>,
) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketNotificationAction)).await
}
/// Checks whether the PutBucketOwnershipControls request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_ownership_controls(&self, _req: &mut S3Request<PutBucketOwnershipControlsInput>) -> S3Result<()> {
Ok(())
}
/// Checks whether the PutBucketPolicy request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_policy(&self, req: &mut S3Request<PutBucketPolicyInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, put_bucket_policy_authorize_action()).await
}
/// Checks whether the PutBucketReplication request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_replication(&self, req: &mut S3Request<PutBucketReplicationInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutReplicationConfigurationAction)).await
}
/// Checks whether the PutBucketRequestPayment request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_request_payment(&self, req: &mut S3Request<PutBucketRequestPaymentInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutBucketTagging request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_tagging(&self, req: &mut S3Request<PutBucketTaggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketTaggingAction)).await
}
/// Checks whether the PutBucketVersioning request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_versioning(&self, req: &mut S3Request<PutBucketVersioningInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketVersioningAction)).await
}
/// Checks whether the PutBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_website(&self, req: &mut S3Request<PutBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, bucket_website_config_authorize_action()).await
}
/// Checks whether the PutObject request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_object(&self, req: &mut S3Request<PutObjectInput>) -> S3Result<()> {
crate::hp_guard!("S3Access::put_object");
async {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
if has_write_offset_bytes_header(&req.headers) {
return Err(S3Error::with_message(
S3ErrorCode::NotImplemented,
ApiError::error_code_to_message(&S3ErrorCode::NotImplemented),
));
}
let bucket = req.input.bucket.clone();
// 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;
#[cfg(test)]
let bucket_generation = if PUT_GENERATION_READ_QUORUM_FAILURE.try_with(|_| ()).is_ok() {
Err(ApiError::from(StorageError::ErasureReadQuorum).into())
} else {
bucket_generation
};
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?);
if req.method != http::Method::POST {
authorize_replication_only_put_headers(req).await?;
}
if legal_hold_write_requested(req.input.object_lock_legal_hold_status.as_ref()) {
authorize_request(req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?;
}
if retention_write_requested(req.input.object_lock_mode.as_ref(), req.input.object_lock_retain_until_date.as_ref()) {
authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?;
}
Ok(())
}
.await
.map_err(normalize_put_read_quorum_error)
}
/// Checks whether the PutObjectAcl request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_object_acl(&self, req: &mut S3Request<PutObjectAclInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(req, Action::S3Action(S3Action::PutObjectAclAction)).await
}
/// Checks whether the PutObjectLegalHold request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_object_legal_hold(&self, req: &mut S3Request<PutObjectLegalHoldInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
authorize_request(req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the PutObjectLockConfiguration request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_object_lock_configuration(&self, req: &mut S3Request<PutObjectLockConfigurationInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketObjectLockConfigurationAction)).await
}
/// Checks whether the PutObjectRetention request has accesses to the resources.
async fn put_object_retention(&self, req: &mut S3Request<PutObjectRetentionInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
set_requested_object_lock_retention(req);
authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?;
// S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission
if has_bypass_governance_header(&req.headers) {
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
}
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the PutObjectTagging request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_object_tagging(&self, req: &mut S3Request<PutObjectTaggingInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(
req,
versioned_tagging_action(S3Action::PutObjectTaggingAction, req.input.version_id.as_deref()),
)
.await
}
/// Checks whether the PutPublicAccessBlock request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_public_access_block(&self, req: &mut S3Request<PutPublicAccessBlockInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_bucket_config_mutation(self, req, Action::S3Action(S3Action::PutBucketPublicAccessBlockAction)).await
}
/// Checks whether the RestoreObject request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn restore_object(&self, req: &mut S3Request<RestoreObjectInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
#[cfg(test)]
wait_for_object_generation_test_hook(&bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(req, Action::S3Action(S3Action::RestoreObjectAction)).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the SelectObjectContent request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn select_object_content(&self, req: &mut S3Request<SelectObjectContentInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, Action::S3Action(S3Action::GetObjectAction)).await
}
/// Checks whether the UploadPart request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn upload_part(&self, req: &mut S3Request<UploadPartInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_staged_multipart_part(req).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the UploadPartCopy request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn upload_part_copy(&self, req: &mut S3Request<UploadPartCopyInput>) -> S3Result<()> {
let bucket = req.input.bucket.clone();
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
let (src_bucket, src_key, version_id) = match &req.input.copy_source {
CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Bucket { bucket, key, version_id } => {
(bucket.to_string(), key.to_string(), version_id.as_ref().map(|v| v.to_string()))
}
};
let source_bucket_generation = load_copy_source_bucket_generation(self, &src_bucket).await;
{
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(src_bucket.clone());
req_info.object = Some(src_key.clone());
req_info.version_id = version_id.clone();
// GHSA-3ppv: a versioned copy source must authorize against
// s3:GetObjectVersion, not s3:GetObject.
authorize_request(req, versioned_read_action(version_id.as_deref())).await?;
}
req.extensions.insert(source_bucket_generation?);
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = None;
authorize_staged_multipart_part(req).await?;
req.extensions.insert(bucket_generation?);
Ok(())
}
/// Checks whether the WriteGetObjectResponse request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn write_get_object_response(&self, _req: &mut S3Request<WriteGetObjectResponseInput>) -> S3Result<()> {
Ok(())
}
}
#[cfg(test)]
#[allow(unused_imports)]
mod tests {
use super::{
AMZ_WRITE_OFFSET_BYTES_HEADER, BucketGenerationGuard, BucketPolicyArgs, BucketPolicyExistingObjectTagHint,
BucketPolicyRawLoadErrorKind, DenialContext, FS, InternalObjectAuthorization, ObjectTagConditions,
PostObjectRequestMarker, ReqInfo, S3Access, StorageError, TABLE_DATA_PLANE_LIST_CURSOR_MAX_ENCODED_LEN,
TableDataPlaneListAccess, TableDataPlaneListCursorPosition, TableDataPlaneListResource, TableDataPlanePublicationGuards,
apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorization_conditions,
bucket_policy_needs_existing_object_tag_from_hint, bucket_website_config_authorize_action,
classify_bucket_policy_raw_load_error, complete_multipart_upload_authorize_action, delete_object_authorize_action,
get_bucket_policy_authorize_action, has_write_offset_bytes_header, install_object_generation_test_hook,
legal_hold_write_requested, list_parts_authorize_action, load_bucket_policy_existing_object_tag_hint,
maybe_merge_object_tag_conditions, merge_list_bucket_query_conditions, merge_request_object_tag_conditions,
owner_can_bypass_policy_deny, post_object_authorize_action, put_bucket_policy_authorize_action, request_context_from_req,
request_object_store, require_owned_reserved_table_object, retention_write_requested, table_data_plane_admin_action,
table_data_plane_content_mutation, table_data_plane_resource_for_request, table_publication_guard_error,
validate_post_object_success_controls, versioned_read_action, versioned_tagging_action,
};
use super::{remaining_retention_days, set_requested_object_lock_retain_until, set_requested_object_lock_retention};
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 _;
use crate::storage::storage_api::contract::object::{ObjectIO as _, ObjectOperations as _};
use crate::storage::storage_api::runtime_sources_consumer::{AppContext, IamInterface, KmsInterface, ServerContextSlot};
use http::{Extensions, HeaderMap, HeaderValue, Method, Uri};
use rustfs_iam::{store::object::ObjectStore, sys::IamSys};
use rustfs_kms::KmsServiceManager;
use rustfs_policy::policy::action::{Action, S3Action};
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, sync::Arc};
use time::OffsetDateTime;
struct UnreadyIam;
impl IamInterface for UnreadyIam {
fn handle(&self) -> Arc<IamSys<ObjectStore>> {
panic!("bucket generation guard tests do not resolve IAM")
}
fn is_ready(&self) -> bool {
false
}
}
struct TestKms;
impl KmsInterface for TestKms {
fn handle(&self) -> Arc<KmsServiceManager> {
Arc::new(KmsServiceManager::new())
}
}
fn build_request<T>(input: T, method: Method) -> S3Request<T> {
S3Request {
input,
method,
uri: Uri::from_static("/"),
headers: HeaderMap::new(),
extensions: Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
fn ensure_req_info<T>(req: &mut S3Request<T>) {
req.extensions.insert(ReqInfo::default());
}
#[test]
fn get_bucket_policy_uses_get_bucket_policy_action() {
assert_eq!(get_bucket_policy_authorize_action(), Action::S3Action(S3Action::GetBucketPolicyAction));
}
#[test]
fn ghsa_3ppv_versioned_read_selects_get_object_version_action() {
// Regression (GHSA-3ppv): a read that names an explicit version must
// authorize against s3:GetObjectVersion so that a principal holding only
// s3:GetObject cannot read historical versions. Applies to GetObject and
// the CopyObject / UploadPartCopy sources.
assert_eq!(
versioned_read_action(Some("0194e0f1-0000-7000-8000-000000000000")),
Action::S3Action(S3Action::GetObjectVersionAction),
"an explicit versionId must require GetObjectVersion"
);
assert_eq!(
versioned_read_action(Some("null")),
Action::S3Action(S3Action::GetObjectVersionAction),
"the sentinel `null` version is still an explicit version selector"
);
assert_eq!(
versioned_read_action(None),
Action::S3Action(S3Action::GetObjectAction),
"a current-object read (no versionId) authorizes against GetObject"
);
}
#[test]
fn put_bucket_policy_uses_put_bucket_policy_action() {
assert_eq!(put_bucket_policy_authorize_action(), Action::S3Action(S3Action::PutBucketPolicyAction));
}
#[test]
fn bucket_website_config_never_authorizes_through_a_read_action() {
let action = bucket_website_config_authorize_action();
// The regression: DeleteBucketWebsite permanently removes the persisted
// website configuration but authorized through s3:GetBucketPolicy, so a
// read-only "may read my bucket policy" grant was enough to destroy it.
assert_ne!(action, Action::S3Action(S3Action::GetBucketPolicyAction));
assert_eq!(action, Action::S3Action(S3Action::PutBucketPolicyAction));
}
#[test]
fn post_object_uses_put_object_action() {
assert_eq!(post_object_authorize_action(), Action::S3Action(S3Action::PutObjectAction));
}
#[test]
fn complete_multipart_upload_uses_put_object_action() {
assert_eq!(complete_multipart_upload_authorize_action(), Action::S3Action(S3Action::PutObjectAction));
}
#[test]
fn list_parts_uses_list_multipart_upload_parts_action() {
assert_eq!(list_parts_authorize_action(), Action::S3Action(S3Action::ListMultipartUploadPartsAction));
}
#[test]
fn table_data_plane_admin_action_maps_s3_object_operations() {
assert_eq!(
table_data_plane_admin_action(Action::S3Action(S3Action::GetObjectAction)),
Some(rustfs_policy::policy::action::AdminAction::GetTableMetadataAction)
);
assert_eq!(
table_data_plane_admin_action(Action::S3Action(S3Action::PutObjectAction)),
Some(rustfs_policy::policy::action::AdminAction::SetTableMetadataAction)
);
assert_eq!(
table_data_plane_admin_action(Action::S3Action(S3Action::PutObjectTaggingAction)),
Some(rustfs_policy::policy::action::AdminAction::SetTableMetadataAction)
);
assert_eq!(
table_data_plane_admin_action(Action::S3Action(S3Action::DeleteObjectAction)),
Some(rustfs_policy::policy::action::AdminAction::SetTableMetadataAction)
);
assert_eq!(
table_data_plane_admin_action(Action::S3Action(S3Action::GetObjectTaggingAction)),
Some(rustfs_policy::policy::action::AdminAction::GetTableMetadataAction)
);
assert_eq!(table_data_plane_admin_action(Action::S3Action(S3Action::ListBucketAction)), None);
}
fn table_list_access(allowed: bool) -> TableDataPlaneListAccess {
TableDataPlaneListAccess {
bucket: "analytics".to_string(),
principal_fingerprint: "principal".to_string(),
snapshot_fingerprint: "snapshot".to_string(),
cursor_key: [0x42; 32],
resources: vec![TableDataPlaneListResource {
resource: crate::table_catalog::TableDataPlaneResource {
table_bucket: "analytics".to_string(),
namespace: "sales".to_string(),
table: "orders".to_string(),
table_id: "table-id".to_string(),
warehouse_object_prefix: "tables/table-id/".to_string(),
},
allowed,
}],
}
}
#[test]
fn table_list_access_hides_unauthorized_table_objects_and_reserved_metadata() {
let denied = table_list_access(false);
assert!(!denied.allows_object("tables/table-id/data/file.parquet"));
assert!(
!denied.allows_object(".rustfs-table/warehouses/default/namespaces/sales/tables/orders/metadata/00001.metadata.json")
);
assert!(!denied.allows_object(".rustfs-table/private/catalog.json"));
assert!(denied.allows_object("ordinary/file.txt"));
let allowed = table_list_access(true);
assert!(allowed.allows_object("tables/table-id/data/file.parquet"));
assert!(
!allowed
.allows_object(".rustfs-table/warehouses/default/namespaces/sales/tables/orders/metadata/00001.metadata.json")
);
assert!(!allowed.allows_object(".rustfs-table/private/catalog.json"));
}
#[test]
fn table_list_cursor_is_confidential_and_bound_to_the_request_snapshot() {
let access = table_list_access(true);
let marker = "tables/hidden/data/file.parquet";
let token = access
.encode_cursor(
"ListObjectsV2",
"tables/",
Some("/"),
1000,
TableDataPlaneListCursorPosition {
marker: Some(marker.to_string()),
version_marker: None,
common_prefix: None,
},
)
.expect("cursor should encode");
assert!(!token.contains(marker));
let decoded = access
.decode_cursor("ListObjectsV2", "tables/", Some("/"), 1000, &token)
.expect("cursor should decode");
assert_eq!(decoded.marker(), Some(marker));
let mut changed = access.clone();
changed.snapshot_fingerprint = "changed".to_string();
assert!(
changed
.decode_cursor("ListObjectsV2", "tables/", Some("/"), 1000, &token)
.is_err()
);
assert!(
access
.decode_cursor("ListObjectsV2", "other/", Some("/"), 1000, &token)
.is_err()
);
let mut rotated_key = access.clone();
rotated_key.cursor_key[0] ^= 1;
assert!(
rotated_key
.decode_cursor("ListObjectsV2", "tables/", Some("/"), 1000, &token)
.is_err()
);
assert!(
access
.decode_cursor(
"ListObjectsV2",
"tables/",
Some("/"),
1000,
&"x".repeat(TABLE_DATA_PLANE_LIST_CURSOR_MAX_ENCODED_LEN + 1),
)
.is_err()
);
}
#[test]
fn reserved_table_metadata_requires_an_active_table_owner() {
let error = require_owned_reserved_table_object::<()>(
".rustfs-table/warehouses/default/namespaces/analytics/tables/events/metadata/00001.metadata.json",
None,
)
.expect_err("unowned reserved metadata must fail closed");
assert_eq!(error.code(), &S3ErrorCode::AccessDenied);
assert!(
require_owned_reserved_table_object("objects/data.parquet", None::<()>)
.unwrap()
.is_none()
);
assert!(
require_owned_reserved_table_object(".rustfs-table/metadata.json", Some(()))
.unwrap()
.is_some()
);
}
#[test]
fn table_data_plane_publication_guard_covers_content_mutations_only() {
for action in [
S3Action::PutObjectAction,
S3Action::DeleteObjectAction,
S3Action::DeleteObjectVersionAction,
S3Action::RestoreObjectAction,
S3Action::ReplicateObjectAction,
S3Action::ReplicateDeleteAction,
S3Action::PutObjectFanOutAction,
] {
assert!(table_data_plane_content_mutation(Action::S3Action(action)));
}
for action in [
S3Action::GetObjectAction,
S3Action::PutObjectTaggingAction,
S3Action::DeleteObjectTaggingAction,
S3Action::ListBucketAction,
] {
assert!(!table_data_plane_content_mutation(Action::S3Action(action)));
}
}
#[test]
fn table_publication_guard_unavailable_errors_are_retryable() {
use crate::table_catalog::TableCatalogStoreError;
for message in [
"failed to acquire catalog migration lock: Quorum not reached: required 2, achieved 1",
"failed to acquire catalog migration lock: lock acquisition timed out after 5s",
"peer unavailable",
"",
] {
let error = TableCatalogStoreError::Unavailable(message.to_string());
let expected_message = format!("failed to acquire table publication guard: {error}");
let err = table_publication_guard_error(error);
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable, "{message}");
assert_eq!(
err.status_code().or_else(|| err.code().status_code()),
Some(http::StatusCode::SERVICE_UNAVAILABLE)
);
assert_eq!(err.message(), Some(expected_message.as_str()));
}
}
#[test]
fn table_publication_guard_non_retryable_errors_stay_internal() {
use crate::table_catalog::TableCatalogStoreError;
let message = "temporarily unavailable: timeout: quorum not reached".to_string();
for error in [
TableCatalogStoreError::Internal(message.clone()),
TableCatalogStoreError::Invalid(message.clone()),
TableCatalogStoreError::Unsupported(message.clone()),
TableCatalogStoreError::NotFound(message.clone()),
TableCatalogStoreError::NamespaceNotFound(message.clone()),
TableCatalogStoreError::TableNotFound(message.clone()),
TableCatalogStoreError::AlreadyExists(message.clone()),
TableCatalogStoreError::Conflict(message),
] {
let expected_message = format!("failed to acquire table publication guard: {error}");
let err = table_publication_guard_error(error);
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(
err.status_code().or_else(|| err.code().status_code()),
Some(http::StatusCode::INTERNAL_SERVER_ERROR)
);
assert_eq!(err.message(), Some(expected_message.as_str()));
}
}
#[tokio::test]
#[serial]
async fn table_publication_guard_timeout_blocks_put_and_multipart_authorization() {
use crate::storage::storage_api::contract::namespace::NamespaceLocking as _;
use std::time::Duration;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)))));
let fs = FS::with_server_ctx(server_ctx);
let bucket = format!("publication-timeout-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("test bucket should be created");
let policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
.await
.expect("test bucket metadata should be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
metadata.policy_config_json = policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("test policy should be published");
let lock_object = crate::table_catalog::default_table_bucket_publication_lock_path();
let lock = store
.new_ns_lock(&bucket, &lock_object)
.await
.expect("publication lock should be created");
let mut writer = lock
.get_write_lock(Duration::from_secs(1))
.await
.expect("publication writer should hold the fence");
let mut put_req = build_request(
PutObjectInput::builder()
.bucket(bucket.clone())
.key("object".to_string())
.build()
.expect("PUT input should build"),
Method::PUT,
);
let mut multipart_req = build_request(
CreateMultipartUploadInput::builder()
.bucket(bucket.clone())
.key("multipart-object".to_string())
.build()
.expect("multipart input should build"),
Method::POST,
);
ensure_req_info(&mut put_req);
ensure_req_info(&mut multipart_req);
put_req.extensions.insert(fs.server_ctx().clone());
multipart_req.extensions.insert(fs.server_ctx().clone());
for (operation, err) in [
(
"PutObject",
fs.put_object(&mut put_req)
.await
.expect_err("PUT must wait for the publication writer"),
),
(
"CreateMultipartUpload",
fs.create_multipart_upload(&mut multipart_req)
.await
.expect_err("multipart initialization must wait for the publication writer"),
),
] {
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable, "{operation}: {err}");
}
for extensions in [&put_req.extensions, &multipart_req.extensions] {
assert!(extensions.get::<TableDataPlanePublicationGuards>().is_none());
}
assert!(writer.release());
for _ in 0..2 {
fs.put_object(&mut put_req)
.await
.expect("PUT may retry after publication finishes");
fs.create_multipart_upload(&mut multipart_req)
.await
.expect("multipart initialization may retry after publication finishes");
for extensions in [&put_req.extensions, &multipart_req.extensions] {
let retained = extensions
.get::<TableDataPlanePublicationGuards>()
.expect("successful admission retains the guard");
let state = retained.state.lock();
assert!(state.keys.contains(&(bucket.clone(), lock_object.clone())));
assert_eq!(state.guards.len(), 1, "repeated admission must reuse its retained guard");
}
}
drop(put_req);
drop(multipart_req);
lock.get_write_lock(Duration::from_secs(1))
.await
.expect("dropping the request must release its publication guard");
}
#[test]
fn table_data_plane_mutations_fence_before_table_bucket_marker_lookup() {
let source = include_str!("access.rs");
for (function, end_marker) in [
("async fn authorize_table_data_plane_if_needed", "/// Authorizes one exact object key"),
(
"async fn deny_anonymous_table_data_plane_if_needed",
"fn validate_post_object_success_controls",
),
] {
let start = source.find(function).expect("authorization helper should exist");
let end = source[start..]
.find(end_marker)
.map(|offset| start + offset)
.expect("authorization helper boundary should exist");
let block = &source[start..end];
let fence = block
.find("retain_table_bucket_publication_guard(req, bucket).await?;")
.expect("content mutation should retain the bucket publication fence");
let marker = block
.find("table_bucket_enabled_for_data_plane(req, bucket).await?")
.expect("authorization should load the table bucket marker");
assert!(fence < marker, "{function} must fence pre-enable writers before reading the marker");
}
}
#[tokio::test]
#[serial]
async fn multipart_parts_skip_table_publication_fence_but_completion_retains_it() {
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms),))));
let fs = FS::with_server_ctx(server_ctx);
let bucket = format!("multipart-table-fence-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("test bucket should be created");
let policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
.await
.expect("new bucket metadata should be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test bucket policy should parse"));
metadata.policy_config_json = policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("test bucket policy should be published");
let mut part_req = build_request(
UploadPartInput::builder()
.bucket(bucket.clone())
.key("object".to_string())
.upload_id("upload-id".to_string())
.part_number(1)
.build()
.expect("upload part input should build"),
Method::PUT,
);
ensure_req_info(&mut part_req);
part_req.extensions.insert(fs.server_ctx().clone());
fs.upload_part(&mut part_req)
.await
.expect("anonymous UploadPart should be authorized by the test policy");
assert!(
part_req.extensions.get::<TableDataPlanePublicationGuards>().is_none(),
"staged part data must not retain a table publication fence"
);
let mut copy_part_req = build_request(
UploadPartCopyInput::builder()
.bucket(bucket.clone())
.key("object".to_string())
.upload_id("upload-id".to_string())
.part_number(2)
.copy_source(CopySource::Bucket {
bucket: bucket.clone().into(),
key: "source".into(),
version_id: None,
})
.build()
.expect("upload part copy input should build"),
Method::PUT,
);
ensure_req_info(&mut copy_part_req);
copy_part_req.extensions.insert(fs.server_ctx().clone());
fs.upload_part_copy(&mut copy_part_req)
.await
.expect("anonymous UploadPartCopy should be authorized by the test policy");
assert!(
copy_part_req.extensions.get::<TableDataPlanePublicationGuards>().is_none(),
"staged copied part data must not retain a table publication fence"
);
let mut complete_req = build_request(
CompleteMultipartUploadInput::builder()
.bucket(bucket.clone())
.key("object".to_string())
.upload_id("upload-id".to_string())
.multipart_upload(Some(CompletedMultipartUpload::default()))
.build()
.expect("complete multipart input should build"),
Method::POST,
);
ensure_req_info(&mut complete_req);
complete_req.extensions.insert(fs.server_ctx().clone());
fs.complete_multipart_upload(&mut complete_req)
.await
.expect("anonymous CompleteMultipartUpload should be authorized by the test policy");
let bucket_fence = (bucket, crate::table_catalog::default_table_bucket_publication_lock_path());
let guards = complete_req
.extensions
.get::<TableDataPlanePublicationGuards>()
.expect("completion should retain a table publication fence");
assert!(guards.state.lock().keys.contains(&bucket_fence));
}
#[tokio::test]
async fn table_data_plane_request_reuses_table_resource_for_distinct_object_while_commit_waits() {
let resource = crate::table_catalog::TableDataPlaneResource {
table_bucket: "warehouse".to_string(),
namespace: "analytics".to_string(),
table: "events".to_string(),
table_id: "table-id".to_string(),
warehouse_object_prefix: "tables/table-id/".to_string(),
};
let cached_key = ("warehouse".to_string(), "tables/table-id/".to_string());
let requested_key = ("warehouse".to_string(), "tables/table-id/data/part-00002.parquet".to_string());
let retained = TableDataPlanePublicationGuards::default();
retained.state.lock().resources.insert(cached_key, resource.clone());
let publication_lock = Arc::new(tokio::sync::Mutex::new(()));
let request_publication_guard = Arc::clone(&publication_lock).lock_owned().await;
retained.state.lock().guards.push(Box::new(request_publication_guard));
let mut req = build_request((), Method::PUT);
req.extensions.insert(retained.clone());
let catalog_lock = Arc::new(tokio::sync::Mutex::new(()));
let commit_started = Arc::new(tokio::sync::Notify::new());
let commit_catalog_lock = Arc::clone(&catalog_lock);
let commit_publication_lock = Arc::clone(&publication_lock);
let commit_started_signal = Arc::clone(&commit_started);
let commit = tokio::spawn(async move {
let _catalog_guard = commit_catalog_lock.lock().await;
commit_started_signal.notify_one();
let _publication_guard = commit_publication_lock.lock().await;
});
commit_started.notified().await;
let resolved = tokio::time::timeout(
std::time::Duration::from_secs(1),
table_data_plane_resource_for_request(&mut req, &requested_key.0, &requested_key.1, true),
)
.await
.expect("cached table resolution must not wait for the catalog lock")
.expect("cached table resolution should succeed");
assert_eq!(resolved, Some(resource));
retained.state.lock().guards.clear();
tokio::time::timeout(std::time::Duration::from_secs(1), commit)
.await
.expect("commit should continue after the request releases its publication guard")
.expect("commit task should join");
}
#[tokio::test]
async fn table_data_plane_request_reuses_reserved_warehouse_resource() {
let resource = crate::table_catalog::TableDataPlaneResource {
table_bucket: "warehouse".to_string(),
namespace: "analytics".to_string(),
table: "events".to_string(),
table_id: "table-id".to_string(),
warehouse_object_prefix: ".rustfs-table/custom-warehouse/events/".to_string(),
};
let retained = TableDataPlanePublicationGuards::default();
retained.state.lock().resources.insert(
(resource.table_bucket.clone(), resource.warehouse_object_prefix.clone()),
resource.clone(),
);
let mut req = build_request((), Method::GET);
req.extensions.insert(retained);
let resolved = table_data_plane_resource_for_request(
&mut req,
"warehouse",
".rustfs-table/custom-warehouse/events/data/part-00001.parquet",
true,
)
.await
.expect("a reserved-prefix warehouse object should use ordinary table resolution");
assert_eq!(resolved, Some(resource));
}
#[tokio::test]
async fn table_data_plane_request_reuses_absence_while_bucket_publication_guard_is_held() {
let key = ("warehouse".to_string(), "tables/new-table/data/file.parquet".to_string());
let bucket_fence = (key.0.clone(), crate::table_catalog::default_table_bucket_publication_lock_path());
let retained = TableDataPlanePublicationGuards::default();
{
let mut state = retained.state.lock();
state.keys.insert(bucket_fence);
state.missing_resources.insert(key.clone());
}
let publication_lock = Arc::new(tokio::sync::Mutex::new(()));
let request_publication_guard = Arc::clone(&publication_lock).lock_owned().await;
retained.state.lock().guards.push(Box::new(request_publication_guard));
let mut req = build_request((), Method::PUT);
req.extensions.insert(retained.clone());
let commit_publication_lock = Arc::clone(&publication_lock);
let commit = tokio::spawn(async move {
let _publication_guard = commit_publication_lock.lock().await;
});
tokio::task::yield_now().await;
let resolved = tokio::time::timeout(
std::time::Duration::from_secs(1),
table_data_plane_resource_for_request(&mut req, &key.0, &key.1, true),
)
.await
.expect("cached absence must not reread the catalog while publication waits")
.expect("cached absence should resolve successfully");
assert!(resolved.is_none());
retained.state.lock().guards.clear();
tokio::time::timeout(std::time::Duration::from_secs(1), commit)
.await
.expect("publication should continue after the request releases its bucket guard")
.expect("publication task should join");
}
#[test]
fn legal_hold_write_requested_is_true_when_status_present() {
assert!(legal_hold_write_requested(Some(&ObjectLockLegalHoldStatus::from_static(
ObjectLockLegalHoldStatus::ON
))));
assert!(!legal_hold_write_requested(None));
}
#[test]
fn retention_write_requested_is_true_when_mode_or_date_present() {
let retain_until = OffsetDateTime::now_utc().into();
assert!(retention_write_requested(
Some(&ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)),
None
));
assert!(retention_write_requested(None, Some(&retain_until)));
assert!(!retention_write_requested(None, None));
}
#[test]
fn validate_post_object_success_controls_accepts_supported_status_codes() {
for status in [200, 201, 204] {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_status(Some(status))
.build()
.expect("post object input should build");
assert!(
validate_post_object_success_controls(&input).is_ok(),
"status {status} should be accepted"
);
}
}
#[test]
fn validate_post_object_success_controls_rejects_invalid_status_code() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_status(Some(202))
.build()
.expect("post object input should build");
let err = validate_post_object_success_controls(&input).expect_err("status 202 should be rejected");
assert_eq!(err.code(), &S3ErrorCode::MalformedPOSTRequest);
}
#[test]
fn validate_post_object_success_controls_accepts_empty_redirect() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_redirect(Some("".to_string()))
.build()
.expect("post object input should build");
assert!(validate_post_object_success_controls(&input).is_ok());
}
#[test]
fn validate_post_object_success_controls_rejects_invalid_redirect() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_redirect(Some("://invalid-url".to_string()))
.build()
.expect("post object input should build");
let err = validate_post_object_success_controls(&input).expect_err("invalid redirect should be rejected");
assert_eq!(err.code(), &S3ErrorCode::MalformedPOSTRequest);
}
/// Object tag conditions must use keys like ExistingObjectTag/<tag-key> so that
/// bucket policy conditions (e.g. s3:ExistingObjectTag/security) are evaluated correctly.
#[test]
fn test_merge_list_bucket_query_conditions_extracts_supported_keys() {
let mut conditions = HashMap::new();
merge_list_bucket_query_conditions(
Action::S3Action(S3Action::ListBucketAction),
Some("prefix=photos%2F2024%2F&delimiter=%2F&max-keys=10&encoding-type=url"),
&mut conditions,
);
assert_eq!(conditions.get("prefix"), Some(&vec!["photos/2024/".to_string()]));
assert_eq!(conditions.get("delimiter"), Some(&vec!["/".to_string()]));
assert_eq!(conditions.get("max-keys"), Some(&vec!["10".to_string()]));
assert!(!conditions.contains_key("encoding-type"));
}
#[test]
fn test_merge_list_bucket_query_conditions_preserves_empty_prefix_signal() {
let mut conditions = HashMap::new();
merge_list_bucket_query_conditions(
Action::S3Action(S3Action::ListBucketVersionsAction),
Some("prefix=&delimiter=%2F"),
&mut conditions,
);
assert_eq!(conditions.get("prefix"), Some(&vec![String::new()]));
assert_eq!(conditions.get("delimiter"), Some(&vec!["/".to_string()]));
}
#[test]
fn test_merge_list_bucket_query_conditions_ignores_non_list_actions() {
let mut conditions = HashMap::new();
merge_list_bucket_query_conditions(
Action::S3Action(S3Action::GetObjectAction),
Some("prefix=photos%2F2024%2F&delimiter=%2F&max-keys=10"),
&mut conditions,
);
assert!(conditions.is_empty());
}
#[test]
fn test_merge_request_object_tag_conditions_applies_only_to_put_object() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-tagging", HeaderValue::from_static("classification=restricted&label=copy%20test"));
let mut source_conditions = HashMap::new();
merge_request_object_tag_conditions(Action::S3Action(S3Action::GetObjectAction), &headers, &mut source_conditions)
.expect("GetObject condition construction should succeed");
assert!(
source_conditions.is_empty(),
"destination request tags must not affect CopyObject source authorization"
);
let mut destination_conditions = HashMap::new();
merge_request_object_tag_conditions(Action::S3Action(S3Action::PutObjectAction), &headers, &mut destination_conditions)
.expect("PutObject condition construction should succeed");
assert_eq!(
destination_conditions.get("RequestObjectTag/classification"),
Some(&vec!["restricted".to_string()])
);
assert_eq!(destination_conditions.get("RequestObjectTag/label"), Some(&vec!["copy test".to_string()]));
assert_eq!(
destination_conditions.get("RequestObjectTagKeys"),
Some(&vec!["classification".to_string(), "label".to_string()])
);
}
#[tokio::test]
async fn versioned_tagging_requires_version_permissions() {
use rustfs_policy::policy::{Args, Policy};
let policy = Policy::parse_config(br#"{
"Version":"2012-10-17",
"Statement":[{"Effect":"Allow","Action":["s3:GetObjectTagging","s3:PutObjectTagging","s3:DeleteObjectTagging"],"Resource":"arn:aws:s3:::bucket/*"}]
}"#).expect("current-object tagging policy should parse");
let conditions = HashMap::new();
let claims = HashMap::new();
let groups = None;
for (current, versioned) in [
(S3Action::GetObjectTaggingAction, S3Action::GetObjectVersionTaggingAction),
(S3Action::PutObjectTaggingAction, S3Action::PutObjectVersionTaggingAction),
(S3Action::DeleteObjectTaggingAction, S3Action::DeleteObjectVersionTaggingAction),
] {
for version in [None, Some("null"), Some("0194e0f1-0000-7000-8000-000000000000")] {
let action = versioned_tagging_action(current, version);
assert_eq!(action, Action::S3Action(if version.is_some() { versioned } else { current }));
let args = Args {
account: "writer",
groups: &groups,
action,
bucket: "bucket",
conditions: &conditions,
is_owner: false,
object: "object",
claims: &claims,
deny_only: false,
};
assert_eq!(policy.is_allowed(&args).await, version.is_none(), "version selector {version:?}");
}
}
}
fn remaining_retention_days_conditions<T>(req: &S3Request<T>, 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")
}
#[tokio::test]
async fn retention_policy_checks_body_instead_of_conflicting_headers() {
use rustfs_policy::policy::{Args, Policy};
let mut req = build_request(
PutObjectRetentionInput {
retention: Some(ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static("COMPLIANCE")),
retain_until_date: Some(
Timestamp::parse(TimestampFormat::DateTime, "2035-01-02T00:00:00+23:59").expect("body date should parse"),
),
}),
..Default::default()
},
Method::PUT,
);
req.headers
.insert("x-amz-object-lock-mode", HeaderValue::from_static("GOVERNANCE"));
req.headers
.insert("x-amz-object-lock-retain-until-date", HeaderValue::from_static("2029-01-01T00:00:00Z"));
set_requested_object_lock_retention(&mut req);
let action = Action::S3Action(S3Action::PutObjectRetentionAction);
let conditions = remaining_retention_days_conditions(&req, action);
assert_eq!(conditions.get("object-lock-mode"), Some(&vec!["COMPLIANCE".to_string()]));
let applied = OffsetDateTime::from(
req.input
.retention
.as_ref()
.expect("body retention")
.retain_until_date
.clone()
.expect("body date"),
);
let authorized = OffsetDateTime::parse(
&conditions["object-lock-retain-until-date"][0],
&time::format_description::well_known::Rfc3339,
)
.expect("condition date should parse");
assert_eq!(authorized.unix_timestamp(), applied.unix_timestamp());
let policy = Policy::parse_config(br#"{
"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObjectRetention","Resource":"arn:aws:s3:::bucket/*",
"Condition":{"DateLessThan":{"s3:object-lock-retain-until-date":"2030-01-01T00:00:00Z"}}}]
}"#).expect("bounded retention policy should parse");
let args = Args {
account: "writer",
groups: &None,
action,
bucket: "bucket",
conditions: &conditions,
is_owner: false,
object: "object",
claims: &HashMap::new(),
deny_only: false,
};
assert!(
!policy.is_allowed(&args).await,
"an in-range header cannot authorize an out-of-range body"
);
req.input.retention = None;
set_requested_object_lock_retention(&mut req);
let conditions = remaining_retention_days_conditions(&req, action);
assert!(!conditions.contains_key("object-lock-mode"));
assert!(!conditions.contains_key("object-lock-retain-until-date"));
}
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);
req.headers.insert(
"authorization",
HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test/20260801/us-east-1/s3tables/aws4_request"),
);
req.headers.insert("user-agent", HeaderValue::from_static("pyiceberg/test"));
req.headers
.insert("x-amz-server-side-encryption", HeaderValue::from_static("AES256"));
req.headers
.insert("x-amz-tagging", HeaderValue::from_static("classification=%ZZ"));
req.extensions.insert(InternalObjectAuthorization);
let credentials = rustfs_credentials::Credentials::default();
let conditions =
authorization_conditions(&req, &credentials, None, None, None, None, Action::S3Action(S3Action::PutObjectAction))
.expect("internal object authorization conditions should build");
assert_eq!(conditions.get("authType"), Some(&vec!["REST-HEADER".to_string()]));
assert_eq!(conditions.get("signatureversion"), Some(&vec!["AWS4-HMAC-SHA256".to_string()]));
assert_eq!(conditions.get("UserAgent"), Some(&vec!["pyiceberg/test".to_string()]));
assert!(!conditions.contains_key("authorization"));
assert!(!conditions.contains_key("x-amz-server-side-encryption"));
assert!(!conditions.contains_key("RequestObjectTag/classification"));
assert!(!conditions.contains_key("RequestObjectTagKeys"));
}
#[tokio::test]
async fn test_required_existing_object_tags_are_merged_into_authorization_conditions() {
let (_temp_dir, _disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::new(AppContext::new(store, Arc::new(UnreadyIam), Arc::new(TestKms),))));
let mut request = build_request((), Method::GET);
request.extensions.insert(server_ctx);
request.extensions.insert(ObjectTagConditions::new(
"bucket",
"tagged-object",
None,
HashMap::from([
("ExistingObjectTag/security".to_string(), vec!["restricted".to_string()]),
("ExistingObjectTag/project".to_string(), vec!["webapp".to_string()]),
]),
));
let mut conditions = HashMap::new();
conditions.insert("delimiter".to_string(), vec!["/".to_string()]);
maybe_merge_object_tag_conditions(
&mut request,
Action::S3Action(S3Action::GetObjectAction),
"bucket",
"tagged-object",
None,
&mut conditions,
true,
)
.await
.expect("merge required existing object tags into authorization conditions");
assert_eq!(conditions.get("ExistingObjectTag/security"), Some(&vec!["restricted".to_string()]));
assert_eq!(conditions.get("ExistingObjectTag/project"), Some(&vec!["webapp".to_string()]));
assert_eq!(conditions.get("delimiter"), Some(&vec!["/".to_string()]));
}
#[test]
fn test_bucket_policy_existing_object_tag_condition_key_detection() {
let condition_key_policy = r#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Principal":"*",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}]
}"#;
let policy: BucketPolicy = serde_json::from_str(condition_key_policy).expect("valid bucket policy JSON");
assert!(
bucket_policy_uses_existing_object_tag_conditions(&policy),
"ExistingObjectTag in condition key must be detected"
);
let value_only_policy = r#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Principal":"*",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:prefix":"ExistingObjectTag/security"}}
}]
}"#;
let policy: BucketPolicy = serde_json::from_str(value_only_policy).expect("valid bucket policy JSON");
assert!(
!bucket_policy_uses_existing_object_tag_conditions(&policy),
"ExistingObjectTag text in values should not trigger tag dependency"
);
}
#[test]
fn test_unparsable_bucket_policy_json_implies_conservative_existing_object_tag_fetch() {
// Matches `load_bucket_policy_existing_object_tag_hint`: unparsable policy => conservative tag fetch.
let malformed = r#"{"Version":"2012-10-17","Statement":[INVALID]}"#;
assert!(serde_json::from_str::<BucketPolicy>(malformed).is_err());
let conservative_fetch = serde_json::from_str::<BucketPolicy>(malformed)
.map(|p| bucket_policy_uses_existing_object_tag_conditions(&p))
.unwrap_or(true);
assert!(conservative_fetch);
// Invalid JSON that still contains real ExistingObjectTag condition keys (trailing comma).
let malformed_with_tag_keys = r#"{"Version":"2012-10-17","Statement":[{"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}},]}"#;
assert!(serde_json::from_str::<BucketPolicy>(malformed_with_tag_keys).is_err());
let conservative_with_tag_keys = serde_json::from_str::<BucketPolicy>(malformed_with_tag_keys)
.map(|p| bucket_policy_uses_existing_object_tag_conditions(&p))
.unwrap_or(true);
assert!(conservative_with_tag_keys);
}
#[test]
fn test_classify_bucket_policy_raw_load_error() {
assert_eq!(
classify_bucket_policy_raw_load_error(&StorageError::ConfigNotFound),
BucketPolicyRawLoadErrorKind::PolicyMissing
);
assert_eq!(
classify_bucket_policy_raw_load_error(&StorageError::BucketNotFound("b".to_string())),
BucketPolicyRawLoadErrorKind::BucketMissing
);
assert_eq!(
classify_bucket_policy_raw_load_error(&StorageError::Io(std::io::Error::other("boom"))),
BucketPolicyRawLoadErrorKind::Other
);
}
/// Owner can bypass bucket policy Deny only for the three policy management APIs (per AWS S3).
#[test]
fn test_owner_can_bypass_policy_deny_only_for_policy_apis() {
// Owner + policy management actions -> bypass allowed
assert!(owner_can_bypass_policy_deny(true, &Action::S3Action(S3Action::GetBucketPolicyAction)));
assert!(owner_can_bypass_policy_deny(true, &Action::S3Action(S3Action::PutBucketPolicyAction)));
assert!(owner_can_bypass_policy_deny(true, &Action::S3Action(S3Action::DeleteBucketPolicyAction)));
// Owner + other actions -> no bypass (still subject to bucket policy Deny)
assert!(!owner_can_bypass_policy_deny(true, &Action::S3Action(S3Action::ListBucketAction)));
assert!(!owner_can_bypass_policy_deny(true, &Action::S3Action(S3Action::GetObjectAction)));
// Non-owner -> no bypass for any action
assert!(!owner_can_bypass_policy_deny(false, &Action::S3Action(S3Action::GetBucketPolicyAction)));
assert!(!owner_can_bypass_policy_deny(
false,
&Action::S3Action(S3Action::DeleteBucketPolicyAction)
));
}
/// Issue #5740: the denial helper must keep the client-facing error identical
/// to the historical bare response — the added diagnostics are log-only.
#[test]
fn test_access_denied_helper_keeps_generic_wire_response() {
let denial = DenialContext {
quiet: false,
bucket: "bucket",
object: "object",
version_id: Some("version"),
account: Some("account"),
is_owner: false,
};
let err = denial.deny("bucket_policy_explicit_deny", Action::S3Action(S3Action::GetObjectAction));
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
assert_eq!(err.message(), Some("Access Denied"));
}
#[test]
fn delete_authorization_selects_the_addressed_version() {
assert_eq!(delete_object_authorize_action(None), Action::S3Action(S3Action::DeleteObjectAction));
for version_id in ["null", "8f418ad0-f9f4-4458-83b2-cc72bc6f1b70"] {
assert_eq!(
delete_object_authorize_action(Some(version_id)),
Action::S3Action(S3Action::DeleteObjectVersionAction)
);
}
}
#[tokio::test]
async fn test_anonymous_version_delete_uses_version_policy_and_tag_hint() {
let policy: BucketPolicy = serde_json::from_str(
r#"{
"Version":"2012-10-17",
"Statement":[
{
"Effect":"Allow",
"Principal":{"AWS":"*"},
"Action":["s3:DeleteObject"],
"Resource":["arn:aws:s3:::bucket/*"]
},
{
"Effect":"Allow",
"Principal":{"AWS":"*"},
"Action":["s3:DeleteObjectVersion"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}
]
}"#,
)
.expect("bucket policy should parse");
let hint = BucketPolicyExistingObjectTagHint::Parsed(policy.clone());
let no_groups: Option<Vec<String>> = None;
let conditions = HashMap::new();
let args_delete = BucketPolicyArgs {
bucket: "bucket",
action: Action::S3Action(S3Action::DeleteObjectAction),
is_owner: false,
account: "",
groups: &no_groups,
conditions: &conditions,
object: "obj",
};
assert!(
policy.is_allowed(&args_delete).await,
"anonymous DeleteObject can be allowed by bucket policy"
);
let args_delete_version = BucketPolicyArgs {
bucket: "bucket",
action: Action::S3Action(S3Action::DeleteObjectVersionAction),
is_owner: false,
account: "",
groups: &no_groups,
conditions: &conditions,
object: "obj",
};
assert!(
!policy.is_allowed(&args_delete_version).await,
"DeleteObjectVersion should still be denied without matching ExistingObjectTag conditions"
);
let needs_tag_current = bucket_policy_needs_existing_object_tag_from_hint(&hint, &args_delete).await;
let needs_tag_version = bucket_policy_needs_existing_object_tag_from_hint(&hint, &args_delete_version).await;
assert!(!needs_tag_current, "DeleteObject statement itself does not require ExistingObjectTag");
assert!(
needs_tag_version,
"DeleteObjectVersion statement requires ExistingObjectTag when version delete is evaluated"
);
assert!(
needs_tag_version,
"the selected version action must require tag fetch for DeleteObject(versionId)"
);
}
#[tokio::test]
async fn post_object_marks_request_extensions() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.expect("post object input should build");
let mut req = S3Request {
input,
method: Method::POST,
uri: Uri::from_static("/"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.extensions.insert(ReqInfo::default());
let fs = FS::new();
let _ = fs.post_object(&mut req).await;
assert!(
req.extensions.get::<PostObjectRequestMarker>().is_some(),
"post object request should carry the marker for downstream handling"
);
}
#[tokio::test]
async fn put_object_rejects_write_offset_bytes_before_authorization() {
let input = PutObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.expect("put object input should build");
let mut headers = HeaderMap::new();
headers.insert(AMZ_WRITE_OFFSET_BYTES_HEADER, http::HeaderValue::from_static("0"));
let mut req = S3Request {
input,
method: Method::PUT,
uri: Uri::from_static("/test-bucket/test-key"),
headers,
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.extensions.insert(ReqInfo::default());
let fs = FS::new();
let err = fs
.put_object(&mut req)
.await
.expect_err("write-offset-bytes requests should be rejected");
assert_eq!(err.code(), &S3ErrorCode::NotImplemented);
assert_eq!(
err.message(),
Some(ApiError::error_code_to_message(&S3ErrorCode::NotImplemented).as_str())
);
let req_info = req.extensions.get::<ReqInfo>().expect("request info should remain available");
assert_eq!(req_info.bucket.as_deref(), Some("test-bucket"));
assert_eq!(req_info.object.as_deref(), Some("test-key"));
}
#[test]
#[serial]
fn odm_read_capture_preserves_access_and_parameter_error_order() {
crate::app::gating_test_env::run_large_stack_test("odm-access-order", || async {
let context = crate::app::gating_test_env::shared_gating_ambient().await;
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::clone(&context)));
let fs = FS::with_server_ctx(server_ctx);
let sys = crate::on_demand_migration::OnDemandMigrationSys::get();
let enabled_before = sys.is_module_enabled();
let bucket = format!("odm-access-missing-{}", uuid::Uuid::new_v4());
for enabled in [false, true] {
sys.set_module_enabled(enabled);
let mut get = build_request(
GetObjectInput {
bucket: bucket.clone(),
key: "key".into(),
part_number: Some(0),
..Default::default()
},
Method::GET,
);
get.extensions.insert(ReqInfo {
cred: Some(rustfs_credentials::Credentials::default()),
is_owner: true,
..Default::default()
});
get.extensions.insert(fs.server_ctx().clone());
let mut head = get.clone().map_input(|_| HeadObjectInput {
bucket: bucket.clone(),
key: "key".into(),
part_number: Some(1),
range: Some(s3s::dto::Range::Int { first: 0, last: Some(1) }),
..Default::default()
});
head.method = Method::HEAD;
let mut list = get.clone().map_input(|_| ListObjectsV2Input {
bucket: bucket.clone(),
max_keys: Some(-1),
..Default::default()
});
fs.get_object(&mut get)
.await
.expect("ordinary GET access must not require bucket identity");
fs.head_object(&mut head)
.await
.expect("ordinary HEAD access must not require bucket identity");
fs.list_objects_v2(&mut list)
.await
.expect("ordinary LIST access must not require bucket identity");
assert!(matches!(
get.extensions.get::<super::OdmReadGenerationGuard>(),
Some(super::OdmReadGenerationGuard::Unavailable)
));
assert!(matches!(
head.extensions.get::<super::OdmReadGenerationGuard>(),
Some(super::OdmReadGenerationGuard::Unavailable)
));
assert!(matches!(
list.extensions.get::<super::OdmReadGenerationGuard>(),
Some(super::OdmReadGenerationGuard::Unavailable)
));
let usecase = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
assert_eq!(
usecase.execute_get_object(get).await.expect_err("bad GET part number").code(),
&S3ErrorCode::InvalidArgument
);
assert_eq!(
usecase
.execute_head_object(head)
.await
.expect_err("range and part number conflict")
.code(),
&S3ErrorCode::InvalidArgument
);
assert_eq!(
crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
.execute_list_objects_v2(list)
.await
.expect_err("negative max keys")
.code(),
&S3ErrorCode::InvalidArgument
);
}
sys.set_module_enabled(enabled_before);
});
}
#[test]
#[serial]
fn odm_read_capture_failure_is_deferred_until_source_miss() {
crate::app::gating_test_env::run_large_stack_test("odm-access-failure", || async {
let context = crate::app::gating_test_env::shared_gating_ambient().await;
let store = context.object_store();
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::clone(&context)));
let fs = FS::with_server_ctx(server_ctx);
let sys = crate::on_demand_migration::OnDemandMigrationSys::get();
let enabled_before = sys.is_module_enabled();
sys.set_module_enabled(true);
let bucket = format!("odm-capture-failure-{}", uuid::Uuid::new_v4());
let mut config: crate::on_demand_migration::OnDemandMigrationConfig = serde_json::from_str(r#"{"source":{"provider":"minio","endpoint":"https://source.example.com","region":"us-east-1","bucket":"source","credentials":{"access_key":"test","secret_key":"test"}}}"#).expect("source config");
config.policy.list_through = true;
sys.apply_for_incarnation(&bucket, uuid::Uuid::new_v4(), Some(&config)).await;
let mut get = build_request(
GetObjectInput {
bucket: bucket.clone(),
key: "local".into(),
..Default::default()
},
Method::GET,
);
get.extensions.insert(ReqInfo {
cred: Some(rustfs_credentials::Credentials::default()),
is_owner: true,
..Default::default()
});
get.extensions.insert(fs.server_ctx().clone());
let mut head = get.clone().map_input(|_| HeadObjectInput {
bucket: bucket.clone(),
key: "local".into(),
..Default::default()
});
head.method = Method::HEAD;
let mut list = get.clone().map_input(|_| ListObjectsV2Input {
bucket: bucket.clone(),
..Default::default()
});
fs.list_objects_v2(&mut list)
.await
.expect("capture failure must not preempt LIST authorization");
fs.get_object(&mut get)
.await
.expect("capture failure must not preempt GET authorization");
fs.head_object(&mut head)
.await
.expect("capture failure must not preempt HEAD authorization");
assert!(matches!(
get.extensions.get::<super::OdmReadGenerationGuard>(),
Some(super::OdmReadGenerationGuard::Failed { .. })
));
assert!(matches!(
head.extensions.get::<super::OdmReadGenerationGuard>(),
Some(super::OdmReadGenerationGuard::Failed { .. })
));
store
.make_bucket(
&bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create bucket after capture");
store
.put_object(
&bucket,
"local",
&mut crate::storage::PutObjReader::from_vec(b"local".to_vec()),
&crate::storage::ObjectOptions::default(),
)
.await
.expect("create local hit");
// Publishing a usable source later must not repair a failed capture.
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("created identity");
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
let usecase = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
usecase
.execute_get_object(get.clone())
.await
.expect("a local GET hit ignores source capture failure");
usecase
.execute_head_object(head.clone())
.await
.expect("a local HEAD hit ignores source capture failure");
get.input.key = "missing".into();
head.input.key = "missing".into();
assert_eq!(
usecase
.execute_get_object(get.clone())
.await
.expect_err("failed capture cannot rebind on GET miss")
.code(),
&S3ErrorCode::NoSuchBucket
);
assert_eq!(
usecase
.execute_head_object(head.clone())
.await
.expect_err("failed capture cannot rebind on HEAD miss")
.code(),
&S3ErrorCode::NoSuchBucket
);
assert_eq!(
crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
.execute_list_objects_v2(list.clone())
.await
.expect_err("failed capture cannot rebind on LIST")
.code(),
&S3ErrorCode::NoSuchBucket
);
config.filter.prefix = Some("remote/".into());
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
assert_eq!(
usecase
.execute_get_object(get.clone())
.await
.expect_err("filtered GET remains local")
.code(),
&S3ErrorCode::NoSuchKey
);
assert_eq!(
usecase
.execute_head_object(head.clone())
.await
.expect_err("filtered HEAD remains local")
.code(),
&S3ErrorCode::NoSuchKey
);
list.input.prefix = Some("local/".into());
let listing = crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
.execute_list_objects_v2(list.clone())
.await
.expect("disjoint prefix needs no source identity");
assert_eq!(listing.output.key_count, Some(0));
list.input.prefix = Some("remote/".into());
list.input.max_keys = Some(0);
let listing = crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
.execute_list_objects_v2(list)
.await
.expect("an empty page needs no source identity");
assert_eq!(listing.output.key_count, Some(0));
config.filter.prefix = None;
config.policy.head = crate::on_demand_migration::HeadPolicy::LocalOnly;
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
assert_eq!(
usecase
.execute_head_object(head.clone())
.await
.expect_err("local-only HEAD needs no source identity")
.code(),
&S3ErrorCode::NoSuchKey
);
config.policy.head = crate::on_demand_migration::HeadPolicy::Proxy;
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
store
.delete_object(
&bucket,
"missing",
crate::storage::ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("create local delete marker");
assert_eq!(
usecase
.execute_get_object(get)
.await
.expect_err("GET respects the delete marker before capture failure")
.code(),
&S3ErrorCode::NoSuchKey
);
assert_eq!(
usecase
.execute_head_object(head)
.await
.expect_err("HEAD respects the delete marker before capture failure")
.code(),
&S3ErrorCode::NoSuchKey
);
sys.remove(&bucket);
sys.set_module_enabled(enabled_before);
});
}
#[tokio::test]
#[serial]
async fn put_read_quorum_access_keeps_authorization_precedence() {
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)))));
let fs = FS::with_server_ctx(server_ctx);
for effect in ["Allow", "Deny"] {
let bucket = format!("put-quorum-access-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("authorization precedence bucket must be created");
let policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"{effect}","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
.await
.expect("test bucket metadata must be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy must parse"));
metadata.policy_config_json = policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("test policy must be installed");
let input = PutObjectInput::builder()
.bucket(bucket)
.key("object".into())
.build()
.expect("access probe input must build");
let mut req = build_request(input, Method::PUT);
ensure_req_info(&mut req);
req.extensions.insert(fs.server_ctx().clone());
let err = super::PUT_GENERATION_READ_QUORUM_FAILURE
.scope((), fs.put_object(&mut req))
.await
.expect_err("a generation failure must never admit a write");
if effect == "Allow" {
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
assert_eq!(err.status_code(), Some(http::StatusCode::SERVICE_UNAVAILABLE));
assert!(matches!(
err.source().and_then(|source| source.downcast_ref::<StorageError>()),
Some(StorageError::ErasureReadQuorum)
));
} else {
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
assert_eq!(err.status_code(), Some(http::StatusCode::FORBIDDEN));
}
assert!(req.extensions.get::<BucketGenerationGuard>().is_none());
}
}
#[tokio::test]
#[serial]
async fn put_object_access_captures_authorized_bucket_incarnation() {
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms),))));
let fs = FS::with_server_ctx(server_ctx);
let bucket = "generation-guard-collision";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("test bucket should be created");
let policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let mut metadata = (*crate::storage::get_bucket_metadata(bucket)
.await
.expect("new bucket metadata should be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test bucket policy should parse"));
metadata.policy_config_json = policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.to_string(), metadata)
.await
.expect("test bucket policy should be published");
let input = PutObjectInput::builder()
.bucket(bucket.to_string())
.key(bucket.to_string())
.build()
.expect("put object input should build");
let mut req = build_request(input, Method::PUT);
ensure_req_info(&mut req);
req.extensions.insert(fs.server_ctx().clone());
fs.put_object(&mut req)
.await
.expect("anonymous PutObject should be authorized by the test policy");
let mut opts = crate::storage::ObjectOptions::default();
apply_bucket_generation_guard(&req, bucket, &mut opts).expect("request snapshot should apply to PutObject options");
assert_eq!(
opts.expected_bucket_incarnation_id,
Some(
store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should remain readable")
)
);
}
#[tokio::test]
#[serial]
async fn request_slot_keeps_bucket_policy_bound_to_its_store() {
let store_a = crate::app::gating_test_env::shared_gating_ecstore().await;
let (_store_b_temp, _store_b_paths, store_b) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
let bucket = format!("request-policy-isolation-{}", uuid::Uuid::new_v4());
for store in [&store_a, &store_b] {
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create isolated policy test bucket");
}
let allow_policy = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let deny_policy = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Deny","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
for (store, policy) in [(&store_a, allow_policy), (&store_b, deny_policy)] {
store
.update_bucket_metadata_config(
&bucket,
crate::storage::storage_api::ecstore_bucket::metadata::BUCKET_POLICY_CONFIG,
policy.into_bytes(),
)
.await
.expect("publish isolated bucket policy");
}
let slot_a = ServerContextSlot::new();
assert!(slot_a.install(Arc::new(AppContext::new(Arc::clone(&store_a), Arc::new(UnreadyIam), Arc::new(TestKms),))));
let slot_b = ServerContextSlot::new();
assert!(slot_b.install(Arc::new(AppContext::new(Arc::clone(&store_b), Arc::new(UnreadyIam), Arc::new(TestKms),))));
let mut request_a = build_request((), Method::PUT);
request_a.extensions.insert(ReqInfo {
bucket: Some(bucket.clone()),
object: Some("object".to_string()),
..Default::default()
});
request_a.extensions.insert(slot_a);
super::authorize_request(&mut request_a, Action::S3Action(S3Action::PutObjectAction))
.await
.expect("store A policy should authorize store A request");
let mut request_b = build_request((), Method::PUT);
request_b.extensions.insert(ReqInfo {
bucket: Some(bucket.clone()),
object: Some("object".to_string()),
..Default::default()
});
request_b.extensions.insert(slot_b);
let err = super::authorize_request(&mut request_b, Action::S3Action(S3Action::PutObjectAction))
.await
.expect_err("store B policy must deny store B request");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
let allow_policy = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
store_b
.update_bucket_metadata_config(
&bucket,
crate::storage::storage_api::ecstore_bucket::metadata::BUCKET_POLICY_CONFIG,
allow_policy.into_bytes(),
)
.await
.expect("replace store B bucket policy");
let slot_b = ServerContextSlot::new();
assert!(slot_b.install(Arc::new(AppContext::new(Arc::clone(&store_b), Arc::new(UnreadyIam), Arc::new(TestKms),))));
let mut allowed_request_b = build_request((), Method::PUT);
allowed_request_b.extensions.insert(ReqInfo {
bucket: Some(bucket.clone()),
object: Some("object".to_string()),
..Default::default()
});
allowed_request_b.extensions.insert(slot_b);
super::authorize_request(&mut allowed_request_b, Action::S3Action(S3Action::PutObjectAction))
.await
.expect("store B policy should allow before its public access block is installed");
store_b
.update_bucket_metadata_config(
&bucket,
crate::storage::storage_api::ecstore_bucket::metadata::BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG,
br#"<PublicAccessBlockConfiguration><RestrictPublicBuckets>true</RestrictPublicBuckets></PublicAccessBlockConfiguration>"#
.to_vec(),
)
.await
.expect("publish store B public access block configuration");
let slot_b = ServerContextSlot::new();
assert!(slot_b.install(Arc::new(AppContext::new(Arc::clone(&store_b), Arc::new(UnreadyIam), Arc::new(TestKms),))));
let mut restricted_request_b = build_request((), Method::PUT);
restricted_request_b.extensions.insert(ReqInfo {
bucket: Some(bucket.clone()),
object: Some("object".to_string()),
..Default::default()
});
restricted_request_b.extensions.insert(slot_b);
let err = super::authorize_request(&mut restricted_request_b, Action::S3Action(S3Action::PutObjectAction))
.await
.expect_err("store B public access block must restrict store B request");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
let tag_bucket = format!("request-tag-isolation-{}", uuid::Uuid::new_v4());
for (store, tags) in [(&store_a, "classification=ambient"), (&store_b, "classification=request")] {
store
.make_bucket(&tag_bucket, &MakeBucketOptions::default())
.await
.expect("create isolated tag test bucket");
let mut reader = crate::storage::PutObjReader::from_vec(b"tagged object".to_vec());
store
.put_object(&tag_bucket, "object", &mut reader, &crate::storage::ObjectOptions::default())
.await
.expect("create isolated tagged object");
store
.put_object_tags(&tag_bucket, "object", tags, &crate::storage::ObjectOptions::default())
.await
.expect("publish isolated object tags");
}
let tag_policy = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{tag_bucket}/*"],"Condition":{{"StringEquals":{{"s3:ExistingObjectTag/classification":"request"}}}}}}]}}"#
);
store_b
.update_bucket_metadata_config(
&tag_bucket,
crate::storage::storage_api::ecstore_bucket::metadata::BUCKET_POLICY_CONFIG,
tag_policy.into_bytes(),
)
.await
.expect("publish store B tag-conditioned policy");
let slot_b = ServerContextSlot::new();
assert!(slot_b.install(Arc::new(AppContext::new(Arc::clone(&store_b), Arc::new(UnreadyIam), Arc::new(TestKms),))));
let mut tagged_request_b = build_request((), Method::PUT);
tagged_request_b.extensions.insert(ReqInfo {
bucket: Some(tag_bucket),
object: Some("object".to_string()),
..Default::default()
});
tagged_request_b.extensions.insert(slot_b);
super::authorize_request(&mut tagged_request_b, Action::S3Action(S3Action::PutObjectAction))
.await
.expect("store B existing object tags should authorize store B request");
let mut unready_request = build_request((), Method::PUT);
unready_request.extensions.insert(ServerContextSlot::new());
let err = request_object_store(&unready_request).expect_err("an unready request slot must not use an ambient store");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[test]
#[serial]
fn delete_object_access_keeps_snapshot_across_recreation_before_admission() {
crate::app::gating_test_env::run_large_stack_test(
"delete-object-generation-guard",
delete_object_access_keeps_snapshot_across_recreation_before_admission_inner,
);
}
async fn delete_object_access_keeps_snapshot_across_recreation_before_admission_inner() {
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let server_ctx = ServerContextSlot::new();
let app_context = Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)));
assert!(server_ctx.install(Arc::clone(&app_context)));
let fs = FS::with_server_ctx(server_ctx);
let bucket = format!("delete-generation-guard-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("test bucket should be created");
assert!(
fs.get_object_tag_conditions_for_policy(&bucket, "missing-object", None)
.await
.expect("a missing object must be treated as having no policy tags")
.is_empty()
);
assert!(
fs.get_object_tag_conditions_for_policy(&format!("missing-{bucket}"), "missing-object", None)
.await
.expect("a missing bucket must be indistinguishable during pre-authorization tag lookup")
.is_empty()
);
let policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:DeleteObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
.await
.expect("new bucket metadata should be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test bucket policy should parse"));
metadata.policy_config_json = policy_json.clone().into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("test bucket policy should be published");
let input = DeleteObjectInput::builder()
.bucket(bucket.clone())
.key("object".to_string())
.build()
.expect("delete object input should build");
let mut req = build_request(input, Method::DELETE);
ensure_req_info(&mut req);
req.extensions.insert(fs.server_ctx().clone());
let captured_incarnation_id = store
.bucket_incarnation_id(&bucket)
.await
.expect("read the bucket generation before admission");
let (loaded_tx, loaded_rx) = tokio::sync::oneshot::channel();
let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
install_object_generation_test_hook(bucket.clone(), loaded_tx, resume_rx);
let access = tokio::spawn(async move {
let result = fs.delete_object(&mut req).await;
(result, req)
});
tokio::time::timeout(std::time::Duration::from_secs(10), loaded_rx)
.await
.expect("DeleteObject should reach the generation snapshot hook")
.expect("DeleteObject access should remain paused before admission");
store
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("delete the authorized bucket generation");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("recreate the same bucket name");
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
.await
.expect("recreated bucket metadata should be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
metadata.policy_config_json = policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("republish the same policy for admission");
let mut reader = crate::storage::PutObjReader::from_vec(b"new generation".to_vec());
store
.put_object(&bucket, "object", &mut reader, &crate::storage::ObjectOptions::default())
.await
.expect("put the new-generation object");
assert_ne!(
captured_incarnation_id,
store
.bucket_incarnation_id(&bucket)
.await
.expect("read the recreated bucket generation")
);
resume_tx
.send(())
.expect("DeleteObject should still be paused before admission");
let (result, req) = access.await.expect("DeleteObject access task should join");
result.expect("the equivalent policy should authorize DeleteObject");
let mut opts = crate::storage::ObjectOptions::default();
apply_bucket_generation_guard(&req, &bucket, &mut opts).expect("request snapshot should apply to DeleteObject options");
assert_eq!(opts.expected_bucket_incarnation_id, Some(captured_incarnation_id));
let err = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(app_context))
.execute_delete_object(req)
.await
.expect_err("the old authorization must not delete from the recreated bucket");
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
store
.get_object_info(&bucket, "object", &crate::storage::ObjectOptions::default())
.await
.expect("the new-generation object must survive the stale request");
}
#[tokio::test]
#[serial]
async fn copy_operations_reject_recreated_source_bucket_after_authorization() {
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let server_ctx = ServerContextSlot::new();
let app_context = Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)));
assert!(server_ctx.install(Arc::clone(&app_context)));
let fs = FS::with_server_ctx(server_ctx);
let suffix = uuid::Uuid::new_v4();
let src_bucket = format!("copy-src-generation-{suffix}");
let dst_bucket = format!("copy-dst-generation-{suffix}");
for bucket in [&src_bucket, &dst_bucket] {
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create copy test bucket");
}
let src_policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::{src_bucket}/*"]}}]}}"#
);
let mut src_metadata = (*crate::storage::get_bucket_metadata(&src_bucket)
.await
.expect("source bucket metadata should be cached"))
.clone();
src_metadata.policy_config = Some(serde_json::from_str(&src_policy_json).expect("source policy should parse"));
src_metadata.policy_config_json = src_policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(src_bucket.clone(), src_metadata)
.await
.expect("publish source policy");
let dst_policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::{dst_bucket}/*"]}}]}}"#
);
let mut dst_metadata = (*crate::storage::get_bucket_metadata(&dst_bucket)
.await
.expect("destination bucket metadata should be cached"))
.clone();
dst_metadata.policy_config = Some(serde_json::from_str(&dst_policy_json).expect("destination policy should parse"));
dst_metadata.policy_config_json = dst_policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(dst_bucket.clone(), dst_metadata)
.await
.expect("publish destination policy");
let input = CopyObjectInput::builder()
.copy_source(CopySource::Bucket {
bucket: src_bucket.clone().into(),
key: "secret".into(),
version_id: None,
})
.bucket(dst_bucket.clone())
.key("copied".to_string())
.build()
.expect("copy input should build");
let mut req = build_request(input, Method::PUT);
ensure_req_info(&mut req);
req.extensions.insert(fs.server_ctx().clone());
fs.copy_object(&mut req)
.await
.expect("test policies should authorize the copy request");
let mut source_opts = crate::storage::ObjectOptions::default();
apply_copy_source_bucket_generation_guard(&req, &src_bucket, &mut source_opts)
.expect("authorized source generation should be attached");
let authorized_source_incarnation_id = source_opts
.expected_bucket_incarnation_id
.expect("source incarnation should be captured");
let upload = store
.new_multipart_upload(&dst_bucket, "multipart-copy", &crate::storage::ObjectOptions::default())
.await
.expect("create destination multipart upload");
let upload_input = UploadPartCopyInput::builder()
.bucket(dst_bucket.clone())
.key("multipart-copy".to_string())
.copy_source(CopySource::Bucket {
bucket: src_bucket.clone().into(),
key: "secret".into(),
version_id: None,
})
.part_number(1)
.upload_id(upload.upload_id)
.build()
.expect("part copy input should build");
let mut upload_req = build_request(upload_input, Method::PUT);
ensure_req_info(&mut upload_req);
upload_req.extensions.insert(fs.server_ctx().clone());
fs.upload_part_copy(&mut upload_req)
.await
.expect("test policies should authorize the part copy request");
store
.delete_bucket(&src_bucket, &DeleteBucketOptions::default())
.await
.expect("delete the authorized source generation");
store
.make_bucket(&src_bucket, &MakeBucketOptions::default())
.await
.expect("recreate the source bucket name");
assert_ne!(
authorized_source_incarnation_id,
store
.bucket_incarnation_id(&src_bucket)
.await
.expect("read recreated source incarnation")
);
let mut reader = crate::storage::PutObjReader::from_vec(b"new generation secret".to_vec());
store
.put_object(&src_bucket, "secret", &mut reader, &crate::storage::ObjectOptions::default())
.await
.expect("write the new-generation source object");
let err = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(Arc::clone(&app_context)))
.execute_copy_object(req)
.await
.expect_err("stale authorization must not read from the recreated source bucket");
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
let err = crate::app::multipart_usecase::DefaultMultipartUsecase::with_context(Some(app_context))
.execute_upload_part_copy(upload_req)
.await
.expect_err("stale authorization must not read the recreated source into a part");
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
store
.get_object_info(&dst_bucket, "copied", &crate::storage::ObjectOptions::default())
.await
.expect_err("the new-generation source object must not be copied");
}
#[test]
#[serial]
fn restore_object_access_keeps_snapshot_across_recreation_before_admission() {
crate::app::gating_test_env::run_large_stack_test(
"restore-object-generation-guard",
restore_object_access_keeps_snapshot_across_recreation_before_admission_inner,
);
}
async fn restore_object_access_keeps_snapshot_across_recreation_before_admission_inner() {
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let server_ctx = ServerContextSlot::new();
let app_context = Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)));
assert!(server_ctx.install(Arc::clone(&app_context)));
let fs = FS::with_server_ctx(server_ctx);
let bucket = format!("restore-generation-guard-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create the bucket generation being authorized");
let authorized_incarnation_id = store
.bucket_incarnation_id(&bucket)
.await
.expect("read the authorized bucket incarnation");
let policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:RestoreObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
.await
.expect("authorized bucket metadata should be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
metadata.policy_config_json = policy_json.clone().into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("publish the RestoreObject policy");
let input = RestoreObjectInput::builder()
.bucket(bucket.clone())
.key("object".to_string())
.restore_request(Some(RestoreRequest {
days: Some(1),
description: None,
glacier_job_parameters: None,
output_location: None,
select_parameters: None,
tier: None,
type_: None,
}))
.build()
.expect("restore object input should build");
let mut req = build_request(input, Method::POST);
ensure_req_info(&mut req);
req.extensions.insert(fs.server_ctx().clone());
let (authorized_tx, authorized_rx) = tokio::sync::oneshot::channel();
let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
install_object_generation_test_hook(bucket.clone(), authorized_tx, resume_rx);
let access = tokio::spawn(async move {
let result = fs.restore_object(&mut req).await;
(result, req)
});
tokio::time::timeout(std::time::Duration::from_secs(10), authorized_rx)
.await
.expect("RestoreObject should reach the generation snapshot hook")
.expect("RestoreObject access must not fail before reaching the test hook");
store
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("delete the authorized bucket generation");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("recreate the bucket with the same name");
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
.await
.expect("recreated bucket metadata should be cached"))
.clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
metadata.policy_config_json = policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("republish the same policy for admission");
let recreated_incarnation_id = store
.bucket_incarnation_id(&bucket)
.await
.expect("read the recreated bucket incarnation");
assert_ne!(authorized_incarnation_id, recreated_incarnation_id);
resume_tx
.send(())
.expect("RestoreObject access should still be waiting at the test hook");
let (result, req) = access.await.expect("RestoreObject access task should join");
result.expect("the equivalent policy should authorize RestoreObject without refreshing its snapshot");
let mut opts = crate::storage::ObjectOptions::default();
apply_bucket_generation_guard(&req, &bucket, &mut opts).expect("apply the RestoreObject authorization guard");
assert_eq!(opts.expected_bucket_incarnation_id, Some(authorized_incarnation_id));
// The RestoreObject usecase future is large enough that heap-pinning a
// single future was not enough on Linux: the whole scenario runs on a
// dedicated large stack (see the `#[test]` wrapper above) so unrelated
// growth in bucket metadata futures cannot tip the test over.
let err = Box::pin(
crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(app_context)).execute_restore_object(req),
)
.await
.expect_err("the old RestoreObject authorization must not reach the recreated bucket");
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
store
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("clean up the recreated bucket");
}
#[test]
fn dispatched_put_object_fails_closed_without_generation_guard() {
let input = PutObjectInput::builder()
.bucket("generation-guard-bucket".to_string())
.key("object".to_string())
.build()
.expect("put object input should build");
let mut req = build_request(input, Method::PUT);
req.extensions.insert(ServerContextSlot::new());
let mut opts = crate::storage::ObjectOptions::default();
let err = apply_bucket_generation_guard(&req, "generation-guard-bucket", &mut opts)
.expect_err("a dispatched PutObject must not commit without its authorization fence");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[test]
fn write_offset_bytes_header_detection_is_case_insensitive() {
let mut headers = HeaderMap::new();
headers.insert("X-Amz-Write-Offset-Bytes", http::HeaderValue::from_static("0"));
assert!(has_write_offset_bytes_header(&headers));
}
#[tokio::test]
async fn put_object_rejects_write_offset_bytes_before_authorize_request() {
let input = PutObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.expect("put object input should build");
let mut req = S3Request {
input,
method: Method::PUT,
uri: Uri::from_static("/test-bucket/test-key"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.headers
.insert("x-amz-write-offset-bytes", http::HeaderValue::from_static("0"));
req.extensions.insert(ReqInfo {
cred: Some(rustfs_credentials::Credentials::default()),
..ReqInfo::default()
});
let err = FS::new()
.put_object(&mut req)
.await
.expect_err("write-offset-bytes requests should be rejected before authorization");
assert_eq!(err.code(), &S3ErrorCode::NotImplemented);
assert_eq!(
err.message(),
Some(ApiError::error_code_to_message(&S3ErrorCode::NotImplemented).as_str())
);
let req_info = req.extensions.get::<ReqInfo>().expect("req info should remain available");
assert_eq!(req_info.bucket.as_deref(), Some("test-bucket"));
assert_eq!(req_info.object.as_deref(), Some("test-key"));
assert_eq!(req_info.version_id, None);
}
#[tokio::test]
async fn delete_objects_defers_object_authorization_to_usecase() {
let input = DeleteObjectsInput::builder()
.bucket("test-bucket".to_string())
.delete(Delete {
objects: vec![ObjectIdentifier {
key: "prefix/test-key".to_string(),
version_id: None,
..Default::default()
}],
quiet: None,
})
.build()
.expect("delete objects input should build");
let mut req = build_request(input, Method::POST);
req.extensions.insert(ReqInfo {
cred: Some(rustfs_credentials::Credentials::default()),
..ReqInfo::default()
});
FS::new()
.delete_objects(&mut req)
.await
.expect("DeleteObjects access hook should not require bucket-level DeleteObject");
let req_info = req.extensions.get::<ReqInfo>().expect("req info should remain available");
assert_eq!(req_info.bucket.as_deref(), Some("test-bucket"));
assert_eq!(req_info.object, None);
assert_eq!(req_info.version_id, None);
}
#[tokio::test]
async fn abort_multipart_upload_fails_closed_when_policy_metadata_is_unavailable() {
let fs = FS::new();
let mut req = build_request(
AbortMultipartUploadInput::builder()
.bucket("bucket".to_string())
.key("object".to_string())
.upload_id("upload-id".to_string())
.build()
.unwrap(),
Method::DELETE,
);
ensure_req_info(&mut req);
let err = fs
.abort_multipart_upload(&mut req)
.await
.expect_err("unavailable policy metadata must fail closed");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn complete_multipart_upload_fails_closed_when_policy_metadata_is_unavailable() {
let fs = FS::new();
let mut req = build_request(
CompleteMultipartUploadInput::builder()
.bucket("bucket".to_string())
.key("object".to_string())
.upload_id("upload-id".to_string())
.multipart_upload(Some(CompletedMultipartUpload::default()))
.build()
.unwrap(),
Method::POST,
);
ensure_req_info(&mut req);
let err = fs
.complete_multipart_upload(&mut req)
.await
.expect_err("unavailable policy metadata must fail closed");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn upload_part_copy_fails_closed_when_policy_metadata_is_unavailable() {
let fs = FS::new();
let mut req = build_request(
UploadPartCopyInput::builder()
.bucket("dst-bucket".to_string())
.key("dst-object".to_string())
.upload_id("upload-id".to_string())
.part_number(1)
.copy_source(CopySource::Bucket {
bucket: "src-bucket".into(),
key: "src-object".into(),
version_id: None,
})
.build()
.unwrap(),
Method::PUT,
);
ensure_req_info(&mut req);
let err = fs
.upload_part_copy(&mut req)
.await
.expect_err("unavailable policy metadata must fail closed");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}