refactor(storage): decide DeleteObject in one owned function

DeleteObject's composite access decision moves out of the legacy S3Access
hook into access_owned::object_misc::authorize_delete_object, generic over
AuthorizationRequest, so the legacy hook and the gateway edge call the same
code. The legacy hook loads the bucket generation, calls it, and installs
the guard it returns; its order and answers do not change: NoSuchBucket
before policy, DeleteObject[Version] for the version storage deletes (the
source-version header for a replica delete), ForceDeleteObject, the
authenticated-caller rule for a recursive force delete, and
BypassGovernanceRetention.

Not built here: the gateway edge of DeleteObject and its use case on the
owned DTOs (they route the legacy body through the seam and wait for the
re-pin).

Tests (access_owned/object_misc_test.rs): one table test runs both
request forms (s3s request and owned envelope) through the decision and
compares the answer and the state handed on (ReqInfo bucket, key, version;
guard ready or failed): 5 refused (missing bucket before policy,
anonymous, caller without a policy, no caller installed, invalid
force-delete value) and 3 allowed (replica delete naming no version gets
its source version, any other delete ignores that header, an unavailable
generation is handed on as a failed guard).

Mutations (each run collects every failing case):
- NoSuchBucket check removed, invalid force-delete read as false, replica
  version swap removed (one build): the missing-bucket, invalid
  force-delete and replica cases went red, in both request forms
- policy decision skipped: the anonymous and no-policy cases went red,
  in both request forms

Verified locally: clippy -D warnings with and without gateway-stack (on
the base before the last rebase, which touched none of these files); 64
delete-related lib tests (app::object::delete, the lifecycle delete tests,
storage::access delete tests) passed, and the parity test passed in its
own run.

Not mutation-checked here: ForceDeleteObject, the authenticated-caller rule
and BypassGovernanceRetention need a caller a policy partly allows; the
existing use-case tests cover the predicate.
This commit is contained in:
overtrue
2026-11-13 15:26:49 +08:00
parent 4a1d542f2d
commit 8eb89dd542
3 changed files with 316 additions and 62 deletions
+6 -62
View File
@@ -12,9 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use self::access_owned::object_misc::authorize_delete_object;
use super::ObjectOptions;
use super::ecfs::FS;
use super::{ECStore, PolicySys, ReplicationStatusType, StorageError, get_lock_acquire_timeout, is_err_bucket_not_found};
use super::{ECStore, PolicySys, StorageError, get_lock_acquire_timeout, is_err_bucket_not_found};
use crate::auth::facts::{
AuthzFacts, AuthzPrincipal, AuthzTarget, base_conditions, build_args, build_conditions, forward_secret_headers,
};
@@ -3339,67 +3340,10 @@ impl S3Access for FS {
let bucket_generation = load_bucket_generation(self.server_ctx(), 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 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());
// A replica delete may name its version through the source-version
// header; the policy decision must see the same version storage deletes.
// Storage consumes that header only for a replica request, so any other
// request is authorized for the version it names in the query alone.
let version_id = if replica_request {
super::options::destination_version_id(req.input.version_id.as_deref(), &req.headers)
} else {
req.input.version_id.clone()
};
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 = version_id;
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()),
};
// 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) {
audit_request_denial(
req,
DenialAuditor::global(),
"recursive_force_delete_requires_authenticated_caller",
Action::S3Action(S3Action::ForceDeleteObjectAction),
);
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.install_pending_delete_bucket_generation(PendingDeleteBucketGenerationGuard::from_result(bucket_generation));
let key = req.input.key.clone();
let version_id = req.input.version_id.clone();
let guard = authorize_delete_object(req, &bucket, &key, version_id.as_deref(), bucket_generation).await?;
req.install_pending_delete_bucket_generation(guard);
Ok(())
}
@@ -20,3 +20,92 @@
//! policy evaluation itself (`authorize_request`), or the operation's use case.
//! Upstream: the legacy hook in `storage/access.rs` and the batch's `server/gateway_handlers/object_misc.rs`.
//! Downstream: the shared shapes in `storage/access.rs`.
//!
//! DeleteObject: the caller loads the bucket generation before the decision and installs the guard
//! returned; the decisions its use case adds afterwards (`ReplicateDelete`, every descendant of a
//! recursive force delete) are not here.
use crate::storage::ReplicationStatusType;
use crate::storage::access::{
AuthorizationRequest, BucketGenerationGuard, PendingDeleteBucketGenerationGuard, S3Error, S3ErrorCode, S3Result,
audit_request_denial, authorize_request, delete_object_authorize_action, has_bypass_governance_header,
invalid_force_delete_header, missing_req_info_error, recursive_force_delete_has_authenticated_caller, s3_error,
};
use crate::storage::denial_audit::DenialAuditor;
use crate::storage::options::destination_version_id;
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_utils::http::{AMZ_BUCKET_REPLICATION_STATUS, force_delete_header};
/// DeleteObject's decision, in the legacy order: `NoSuchBucket` before policy, then
/// `DeleteObject[Version]` for the version storage deletes (a replica delete names it through its
/// source-version header), `ForceDeleteObject` for a force delete, the authenticated-caller rule
/// for a recursive force delete, and `BypassGovernanceRetention` when the header asks for it.
///
/// `bucket_generation` is the bucket's generation as loaded before the decision; the guard it
/// becomes is returned for the caller to install.
pub(crate) async fn authorize_delete_object<R: AuthorizationRequest + ?Sized>(
req: &mut R,
bucket: &str,
key: &str,
version_id: Option<&str>,
bucket_generation: S3Result<BucketGenerationGuard>,
) -> S3Result<PendingDeleteBucketGenerationGuard> {
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 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());
// Storage reads the source-version header only for a replica request, so any other request is
// authorized for the version its query names.
let version_id = if replica_request {
destination_version_id(version_id, req.headers())
} else {
version_id.map(str::to_owned)
};
let req_info = req.req_info_mut().ok_or_else(missing_req_info_error)?;
req_info.bucket = Some(bucket.to_owned());
req_info.object = Some(key.to_owned());
req_info.version_id = version_id;
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()),
};
// The REPLICA header is caller-supplied, so it does not skip this check; `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) {
audit_request_denial(
req,
DenialAuditor::global(),
"recursive_force_delete_requires_authenticated_caller",
Action::S3Action(S3Action::ForceDeleteObjectAction),
);
return Err(s3_error!(AccessDenied, "Recursive force-delete requires an authenticated caller"));
}
if has_bypass_governance_header(req.headers()) {
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
}
Ok(PendingDeleteBucketGenerationGuard::from_result(bucket_generation))
}
#[cfg(test)]
#[path = "object_misc_test.rs"]
mod tests;
@@ -0,0 +1,221 @@
// 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.
//! Both request forms through the one decision: the same answer and the same state handed on
//! (the caller's `ReqInfo` and the guard), allowed and refused.
use super::authorize_delete_object;
use crate::app::RequestEnvelope;
use crate::storage::access::{
AuthorizationRequest, BucketGenerationGuard, PendingDeleteBucketGenerationGuard, ReqInfo, S3Error, S3ErrorCode, S3Request,
S3Result,
};
use crate::storage::storage_api::runtime_sources_consumer::ServerContextSlot;
use http::{Extensions, HeaderMap, HeaderValue, Method, Uri};
use rustfs_utils::http::{AMZ_BUCKET_REPLICATION_STATUS, SUFFIX_SOURCE_VERSION_ID, insert_header};
use std::sync::Arc;
const BUCKET: &str = "e2-delete-parity";
const QUERY_VERSION: &str = "11111111-1111-1111-1111-111111111111";
const SOURCE_VERSION: &str = "22222222-2222-2222-2222-222222222222";
struct Case {
name: &'static str,
headers: &'static [(&'static str, &'static str)],
query_version: Option<&'static str>,
caller: Option<ReqInfo>,
generation: fn() -> S3Result<BucketGenerationGuard>,
expected: Result<Handed, S3ErrorCode>,
}
/// What an allowed decision hands on: the version it authorized and whether the guard is ready.
#[derive(Debug, PartialEq)]
struct Handed {
version_id: Option<String>,
guard_ready: bool,
}
fn ready() -> S3Result<BucketGenerationGuard> {
Ok(BucketGenerationGuard {
bucket: BUCKET.to_owned(),
incarnation_id: uuid::Uuid::nil(),
})
}
fn missing_bucket() -> S3Result<BucketGenerationGuard> {
Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "gone"))
}
fn unavailable() -> S3Result<BucketGenerationGuard> {
Err(S3Error::with_message(S3ErrorCode::ServiceUnavailable, "metadata unavailable"))
}
fn owner() -> Option<ReqInfo> {
Some(ReqInfo {
cred: Some(rustfs_credentials::Credentials::default()),
is_owner: true,
..Default::default()
})
}
fn stranger() -> Option<ReqInfo> {
Some(ReqInfo {
cred: Some(rustfs_credentials::Credentials {
access_key: "e2-delete-parity-stranger".to_owned(),
..Default::default()
}),
..Default::default()
})
}
fn anonymous() -> Option<ReqInfo> {
Some(ReqInfo::default())
}
const REPLICA: (&str, &str) = (AMZ_BUCKET_REPLICATION_STATUS, "REPLICA");
fn cases() -> Vec<Case> {
let handed = |version: Option<&str>, guard_ready| {
Ok(Handed {
version_id: version.map(str::to_owned),
guard_ready,
})
};
vec![
Case {
name: "a missing bucket is named before policy, even to an anonymous caller",
headers: &[],
query_version: Some(QUERY_VERSION),
caller: anonymous(),
generation: missing_bucket,
expected: Err(S3ErrorCode::NoSuchBucket),
},
Case {
name: "anonymous",
headers: &[],
query_version: Some(QUERY_VERSION),
caller: anonymous(),
generation: ready,
expected: Err(S3ErrorCode::AccessDenied),
},
Case {
name: "a caller without a policy",
headers: &[],
query_version: Some(QUERY_VERSION),
caller: stranger(),
generation: ready,
expected: Err(S3ErrorCode::AccessDenied),
},
Case {
name: "no caller installed",
headers: &[],
query_version: Some(QUERY_VERSION),
caller: None,
generation: ready,
expected: Err(S3ErrorCode::InternalError),
},
Case {
name: "an invalid force-delete value",
headers: &[("x-rustfs-force-delete", "maybe")],
query_version: Some(QUERY_VERSION),
caller: owner(),
generation: ready,
expected: Err(S3ErrorCode::InvalidRequest),
},
Case {
name: "a replica delete naming no version is authorized for its source version",
headers: &[REPLICA],
query_version: None,
caller: owner(),
generation: ready,
expected: handed(Some(SOURCE_VERSION), true),
},
Case {
name: "any other delete naming no version ignores the source-version header",
headers: &[],
query_version: None,
caller: owner(),
generation: ready,
expected: handed(None, true),
},
Case {
name: "an unavailable generation is handed on as a failed guard",
headers: &[],
query_version: Some(QUERY_VERSION),
caller: owner(),
generation: unavailable,
expected: handed(Some(QUERY_VERSION), false),
},
]
}
fn request(case: &Case, server_ctx: &Arc<ServerContextSlot>) -> S3Request<()> {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_VERSION_ID, SOURCE_VERSION);
for (name, value) in case.headers {
headers.insert(*name, HeaderValue::from_static(value));
}
let mut extensions = Extensions::new();
if let Some(caller) = &case.caller {
extensions.insert(caller.clone());
}
extensions.insert(Arc::clone(server_ctx));
S3Request {
input: (),
method: Method::DELETE,
uri: Uri::from_static("/e2-delete-parity/key"),
headers,
extensions,
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
async fn decide<R: AuthorizationRequest + ?Sized>(req: &mut R, case: &Case) -> Result<Handed, S3ErrorCode> {
let guard = authorize_delete_object(req, BUCKET, "key", case.query_version, (case.generation)())
.await
.map_err(|error| error.code().clone())?;
let info = req.req_info().expect("an allowed decision keeps the caller");
assert_eq!((info.bucket.as_deref(), info.object.as_deref()), (Some(BUCKET), Some("key")));
Ok(Handed {
version_id: info.version_id.clone(),
guard_ready: matches!(guard, PendingDeleteBucketGenerationGuard::Ready(_)),
})
}
#[test]
fn both_request_forms_decide_and_hand_on_the_same() {
crate::app::gating_test_env::run_large_stack_test("e2-delete-object-parity", || async {
let context = crate::app::gating_test_env::shared_gating_ambient().await;
let server_ctx = ServerContextSlot::new();
assert!(server_ctx.install(Arc::clone(&context)));
// Every case runs, so one run names every case a defect breaks.
let mut wrong = Vec::new();
for case in cases() {
let mut legacy = request(&case, &server_ctx);
let mut envelope = RequestEnvelope::from_s3s(&legacy);
for (form, answer) in [
("legacy request", decide(&mut legacy, &case).await),
("envelope", decide(&mut envelope, &case).await),
] {
if answer != case.expected {
wrong.push(format!("{} ({form}): {answer:?}", case.name));
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
});
}