Files
rustfs/crates/protocols/src/common/gateway.rs
T
Zhengchao An 70a2441407 refactor: route notify dispatch through app context (#3789)
* refactor: route notify dispatch through app context

* refactor: route admin IAM globals through app context (#3791)

* refactor: centralize IAM root credential access (#3792)
2026-06-23 20:05:28 +08:00

585 lines
25 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 rustfs_policy::policy::action::S3Action as PolicyS3Action;
use serde_json;
use std::collections::HashMap;
use thiserror::Error;
use tracing::error;
use super::session::SessionContext;
/// Authorization errors
#[derive(Debug, Error)]
pub enum AuthorizationError {
/// Policy denied the principal the requested action. Distinct
/// from IamUnavailable so protocol drivers can map a deny to
/// PermissionDenied while mapping a transient IAM outage to
/// the spec-equivalent Failure (no SFTPv3 service-unavailable
/// status exists).
#[error("Access denied")]
AccessDenied,
/// The IAM layer was unreachable or returned an error other
/// than the expected Allow/Deny verdict. Indistinguishable
/// from AccessDenied at the wire boundary in earlier
/// implementations; protocol drivers now branch on this
/// variant to surface a warn log naming the failing
/// operation so operators can correlate session errors with
/// IAM degradation.
#[error("IAM system unavailable")]
IamUnavailable,
}
/// S3 actions that can be performed through the gateway
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum S3Action {
// Bucket operations
CreateBucket,
DeleteBucket,
ListBucket,
ListBuckets,
HeadBucket,
// Object operations
GetObject,
PutObject,
DeleteObject,
HeadObject,
CopyObject,
// Multipart operations
CreateMultipartUpload,
UploadPart,
CompleteMultipartUpload,
AbortMultipartUpload,
ListMultipartUploads,
ListParts,
// ACL operations
GetBucketAcl,
PutBucketAcl,
GetObjectAcl,
PutObjectAcl,
}
impl From<S3Action> for PolicyS3Action {
fn from(action: S3Action) -> Self {
match action {
S3Action::CreateBucket => PolicyS3Action::CreateBucketAction,
S3Action::DeleteBucket => PolicyS3Action::DeleteBucketAction,
S3Action::ListBucket => PolicyS3Action::ListBucketAction,
S3Action::ListBuckets => PolicyS3Action::ListAllMyBucketsAction,
S3Action::HeadBucket => PolicyS3Action::HeadBucketAction,
S3Action::GetObject => PolicyS3Action::GetObjectAction,
S3Action::PutObject => PolicyS3Action::PutObjectAction,
S3Action::DeleteObject => PolicyS3Action::DeleteObjectAction,
S3Action::HeadObject => PolicyS3Action::GetObjectAction,
S3Action::CreateMultipartUpload => PolicyS3Action::PutObjectAction,
S3Action::UploadPart => PolicyS3Action::PutObjectAction,
S3Action::CompleteMultipartUpload => PolicyS3Action::PutObjectAction,
S3Action::AbortMultipartUpload => PolicyS3Action::AbortMultipartUploadAction,
S3Action::ListMultipartUploads => PolicyS3Action::ListBucketMultipartUploadsAction,
S3Action::ListParts => PolicyS3Action::ListMultipartUploadPartsAction,
S3Action::GetBucketAcl => PolicyS3Action::GetBucketAclAction,
S3Action::PutBucketAcl => PolicyS3Action::PutBucketAclAction,
S3Action::GetObjectAcl => PolicyS3Action::GetObjectAclAction,
S3Action::PutObjectAcl => PolicyS3Action::PutObjectAclAction,
S3Action::CopyObject => PolicyS3Action::PutObjectAction,
}
}
}
impl From<S3Action> for rustfs_policy::policy::action::Action {
fn from(action: S3Action) -> Self {
rustfs_policy::policy::action::Action::S3Action(action.into())
}
}
impl S3Action {
/// Get the string representation of the action
pub fn as_str(&self) -> &'static str {
match self {
S3Action::CreateBucket => "s3:CreateBucket",
S3Action::DeleteBucket => "s3:DeleteBucket",
S3Action::ListBucket => "s3:ListBucket",
S3Action::ListBuckets => "s3:ListAllMyBuckets",
S3Action::HeadBucket => "s3:ListBucket",
S3Action::GetObject => "s3:GetObject",
S3Action::PutObject => "s3:PutObject",
S3Action::DeleteObject => "s3:DeleteObject",
S3Action::HeadObject => "s3:GetObject",
S3Action::CreateMultipartUpload => "s3:PutObject",
S3Action::UploadPart => "s3:PutObject",
S3Action::CompleteMultipartUpload => "s3:PutObject",
S3Action::AbortMultipartUpload => "s3:AbortMultipartUpload",
S3Action::ListMultipartUploads => "s3:ListBucketMultipartUploads",
S3Action::ListParts => "s3:ListMultipartUploadParts",
S3Action::GetBucketAcl => "s3:GetBucketAcl",
S3Action::PutBucketAcl => "s3:PutBucketAcl",
S3Action::GetObjectAcl => "s3:GetObjectAcl",
S3Action::PutObjectAcl => "s3:PutObjectAcl",
S3Action::CopyObject => "s3:PutObject",
}
}
}
/// Check if operation is supported for the given protocol
pub fn is_operation_supported(protocol: super::session::Protocol, action: &S3Action) -> bool {
match protocol {
super::session::Protocol::Ftps => match action {
// Bucket operations
S3Action::CreateBucket => true,
S3Action::DeleteBucket => true,
// Object operations
S3Action::GetObject => true, // RETR command
S3Action::PutObject => true, // STOR and APPE commands both map to PutObject
S3Action::DeleteObject => true, // DELE command
S3Action::HeadObject => true, // SIZE command
// Multipart operations
S3Action::CreateMultipartUpload => false,
S3Action::UploadPart => false,
S3Action::CompleteMultipartUpload => false,
S3Action::AbortMultipartUpload => false,
S3Action::ListMultipartUploads => false,
S3Action::ListParts => false,
// ACL operations
S3Action::GetBucketAcl => false,
S3Action::PutBucketAcl => false,
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
// Other operations
S3Action::CopyObject => false, // No native copy support in FTPS
S3Action::ListBucket => true, // LIST command
S3Action::ListBuckets => true, // LIST at root level
S3Action::HeadBucket => true, // Can check if directory exists
},
super::session::Protocol::Swift => match action {
// Swift supports most S3 operations via translation
S3Action::CreateBucket => true, // PUT container
S3Action::DeleteBucket => true, // DELETE container
S3Action::GetObject => true, // GET object
S3Action::PutObject => true, // PUT object
S3Action::DeleteObject => true, // DELETE object
S3Action::HeadObject => true, // HEAD object
S3Action::CopyObject => true, // COPY method
S3Action::ListBucket => true, // GET container
S3Action::ListBuckets => true, // GET account
S3Action::HeadBucket => true, // HEAD container
// Multipart not directly supported by Swift API (uses different approach)
S3Action::CreateMultipartUpload => false,
S3Action::UploadPart => false,
S3Action::CompleteMultipartUpload => false,
S3Action::AbortMultipartUpload => false,
S3Action::ListMultipartUploads => false,
S3Action::ListParts => false,
// ACL operations not supported by Swift API (uses different model)
S3Action::GetBucketAcl => false,
S3Action::PutBucketAcl => false,
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
},
super::session::Protocol::WebDav => match action {
// Bucket operations
S3Action::CreateBucket => true, // MKCOL at root level
S3Action::DeleteBucket => true, // DELETE at root level
S3Action::ListBucket => true, // PROPFIND
S3Action::ListBuckets => true, // PROPFIND at root
S3Action::HeadBucket => true, // PROPFIND/HEAD
// Object operations
S3Action::GetObject => true, // GET
S3Action::PutObject => true, // PUT
S3Action::DeleteObject => true, // DELETE
S3Action::HeadObject => true, // HEAD/PROPFIND
S3Action::CopyObject => false, // COPY (not implemented yet)
// Multipart operations (not supported in WebDAV)
S3Action::CreateMultipartUpload => false,
S3Action::UploadPart => false,
S3Action::CompleteMultipartUpload => false,
S3Action::AbortMultipartUpload => false,
S3Action::ListMultipartUploads => false,
S3Action::ListParts => false,
// ACL operations (not supported in WebDAV)
S3Action::GetBucketAcl => false,
S3Action::PutBucketAcl => false,
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
},
super::session::Protocol::Sftp => match action {
// Bucket operations: SFTP exposes top-level buckets as directories.
S3Action::CreateBucket => true, // MKDIR at the root
S3Action::DeleteBucket => true, // RMDIR at the root
S3Action::ListBucket => true, // OPENDIR/READDIR within a bucket
S3Action::ListBuckets => true, // OPENDIR/READDIR at the root
S3Action::HeadBucket => true, // STAT/LSTAT of a bucket entry
// Object operations
S3Action::GetObject => true, // OPEN/READ
S3Action::PutObject => true, // OPEN(WRITE)/WRITE/CLOSE
S3Action::DeleteObject => true, // REMOVE
S3Action::HeadObject => true, // STAT/LSTAT/FSTAT
S3Action::CopyObject => true, // RENAME maps to copy + delete
// Multipart operations: streamed PUT path used by the write driver.
S3Action::CreateMultipartUpload => true,
S3Action::UploadPart => true,
S3Action::CompleteMultipartUpload => true,
S3Action::AbortMultipartUpload => true,
S3Action::ListMultipartUploads => false,
S3Action::ListParts => false,
// ACL operations: SFTP has no equivalent surface.
S3Action::GetBucketAcl => false,
S3Action::PutBucketAcl => false,
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
},
}
}
/// Check if a principal is allowed to perform an S3 action.
/// Returns Ok(true) when the policy allows the action, Ok(false) when
/// the policy denies it, and Err(AuthorizationError::IamUnavailable)
/// when the IAM layer is unreachable (rustfs_iam::get fails). The
/// IamUnavailable case is distinct from a Deny so protocol drivers
/// can return a transient-failure status with a warn log instead of
/// the permanent permission-denied status that a Deny produces.
pub async fn is_authorized(
session_context: &SessionContext,
action: &S3Action,
bucket: &str,
object: Option<&str>,
) -> Result<bool, AuthorizationError> {
let iam_sys = match rustfs_iam::get() {
Ok(sys) => sys,
Err(e) => {
error!(
event = "protocol_gateway_authz_state",
component = "protocols",
subsystem = "gateway",
result = "iam_unavailable",
action = action.as_str(),
bucket = %bucket,
object = %object.unwrap_or_default(),
error = %e,
"protocol gateway authz state changed"
);
return Err(AuthorizationError::IamUnavailable);
}
};
// Create policy arguments
let mut claims = HashMap::new();
claims.insert(
"principal".to_string(),
serde_json::Value::String(session_context.principal.access_key().to_string()),
);
let policy_action: rustfs_policy::policy::action::Action = action.clone().into();
// Check if user is the owner (admin)
let is_owner = rustfs_iam::is_root_access_key(session_context.principal.access_key());
let args = rustfs_policy::policy::Args {
account: session_context.principal.access_key(),
groups: &session_context.principal.user_identity.credentials.groups,
action: policy_action,
bucket,
conditions: &HashMap::new(),
is_owner,
object: object.unwrap_or(""),
claims: &claims,
deny_only: false,
};
Ok(iam_sys.is_allowed(&args).await)
}
/// Authorize an operation and return an error if not authorized.
/// AccessDenied covers both the protocol-not-supported case and the
/// policy-denies case. IamUnavailable propagates from is_authorized
/// when the IAM layer is unreachable; protocol drivers map it to a
/// transient-failure status with a warn log rather than the
/// permanent permission-denied status that AccessDenied produces.
pub async fn authorize_operation(
session_context: &SessionContext,
action: &S3Action,
bucket: &str,
object: Option<&str>,
) -> Result<(), AuthorizationError> {
// SECURITY: the next two lines are cfg(test)-gated. Release builds strip
// them and run only the IAM path below. Implementation and verification
// recipe are in the test_auth_override submodule at the bottom of this file.
#[cfg(test)]
if let Some(decision) = test_auth_override::consult(action, bucket, object) {
return decision;
}
// check if the operation is supported
if !is_operation_supported(session_context.protocol, action) {
return Err(AuthorizationError::AccessDenied);
}
// check IAM authorization
match is_authorized(session_context, action, bucket, object).await {
Ok(true) => Ok(()),
Ok(false) => Err(AuthorizationError::AccessDenied),
Err(e) => Err(e),
}
}
/// Test-only authorisation override for driver-level unit tests.
///
/// Every item in this module is gated on #[cfg(test)], and the single
/// call site in authorize_operation is also #[cfg(test)]-gated, so
/// release builds contain none of this code and run only the IAM path.
///
/// A unit test installs a decide closure via with_test_auth_override,
/// runs an async body that calls authorize_operation, and the override
/// is cleared on scope exit by a Drop guard so a panic inside the body
/// cannot leak the decision into later tests on the same thread.
#[cfg(test)]
pub mod test_auth_override {
use super::{AuthorizationError, S3Action};
use std::cell::{Cell, RefCell};
type DecideFn = Box<dyn Fn(&S3Action, &str, Option<&str>) -> bool>;
thread_local! {
/// Current per-thread Allow/Deny override. None means no test
/// has installed one and authorize_operation falls through to
/// its IAM path.
static OVERRIDE: RefCell<Option<DecideFn>> = const { RefCell::new(None) };
/// Per-thread IAM-unavailable injection. When true, consult
/// short-circuits with IamUnavailable so tests can verify the
/// IAM-outage branch without standing up a real degraded IAM
/// fixture. Takes precedence over the Allow/Deny OVERRIDE.
static IAM_UNAVAILABLE: Cell<bool> = const { Cell::new(false) };
}
/// Consult the per-thread overrides. IamUnavailable takes
/// precedence over the Allow/Deny override so a test combining
/// both flags can verify that the unavailable branch fires before
/// any policy evaluation. Returns Some(decision) when any
/// override is active on the current thread, None otherwise.
/// Called exclusively from authorize_operation's cfg(test)-gated
/// fast path.
pub(super) fn consult(action: &S3Action, bucket: &str, object: Option<&str>) -> Option<Result<(), AuthorizationError>> {
if IAM_UNAVAILABLE.with(|c| c.get()) {
return Some(Err(AuthorizationError::IamUnavailable));
}
OVERRIDE.with(|cell| {
cell.borrow().as_ref().map(|decide| {
if decide(action, bucket, object) {
Ok(())
} else {
Err(AuthorizationError::AccessDenied)
}
})
})
}
/// Install a test-only authorisation decision for the duration of the
/// supplied async body, then clear it. A Drop guard performs the
/// clearing so a panic inside the body does not leak the decision
/// into later tests on the same thread.
///
/// Example:
/// let result = with_test_auth_override(
/// |_action, _bucket, _object| true,
/// async { authorize_operation(&ctx, &action, "b", None).await },
/// ).await;
pub async fn with_test_auth_override<Fut, R>(decide: impl Fn(&S3Action, &str, Option<&str>) -> bool + 'static, body: Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
OVERRIDE.with(|cell| *cell.borrow_mut() = None);
}
}
OVERRIDE.with(|cell| *cell.borrow_mut() = Some(Box::new(decide)));
let _reset = Reset;
body.await
}
/// Inject AuthorizationError::IamUnavailable for every
/// authorize_operation call inside the supplied async body, then
/// clear the flag on scope exit (Drop guard handles the panic
/// case). Used by the IAM-outage tests that verify protocol
/// drivers map the unreachable variant to a transient-failure
/// status with a warn log rather than to PermissionDenied.
pub async fn with_test_iam_unavailable<Fut, R>(body: Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
IAM_UNAVAILABLE.with(|c| c.set(false));
}
}
IAM_UNAVAILABLE.with(|c| c.set(true));
let _reset = Reset;
body.await
}
}
/// Ergonomic re-export so tests reach the helpers via
/// common::gateway::with_test_auth_override rather than nesting
/// the submodule path.
#[cfg(test)]
pub use test_auth_override::{with_test_auth_override, with_test_iam_unavailable};
#[cfg(test)]
mod tests {
use super::*;
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext};
use rustfs_policy::auth::UserIdentity;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
fn test_session() -> SessionContext {
let principal = ProtocolPrincipal::new(Arc::new(UserIdentity::default()));
SessionContext::new(principal, Protocol::Sftp, IpAddr::V4(Ipv4Addr::LOCALHOST))
}
#[tokio::test]
async fn with_test_auth_override_allow_returns_ok() {
let session = test_session();
let result = with_test_auth_override(|_action, _bucket, _object| true, async {
authorize_operation(&session, &S3Action::GetObject, "b", None).await
})
.await;
assert!(result.is_ok(), "override returning true must make authorize_operation succeed");
}
#[tokio::test]
async fn with_test_auth_override_deny_returns_err() {
let session = test_session();
let result = with_test_auth_override(|_action, _bucket, _object| false, async {
authorize_operation(&session, &S3Action::PutObject, "b", Some("k")).await
})
.await;
assert!(matches!(result, Err(AuthorizationError::AccessDenied)));
}
#[tokio::test]
async fn with_test_auth_override_clears_after_body() {
let session = test_session();
// Discard the body Result. The test exercises the clear-on-return
// side-effect of with_test_auth_override, not the body's outcome.
let _ = with_test_auth_override(|_, _, _| true, async { Result::<(), ()>::Ok(()) }).await;
// After the helper returns, the IAM path runs. IAM is not
// initialised in this test binary, so is_authorized returns
// IamUnavailable. A leaked override would have produced Ok.
let result = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
}
#[tokio::test]
async fn with_test_auth_override_closure_sees_action_bucket_object() {
let session = test_session();
let result = with_test_auth_override(
|action, bucket, object| {
matches!(action, S3Action::UploadPart) && bucket == "only-this-bucket" && object == Some("only-this-key")
},
async {
let allowed =
authorize_operation(&session, &S3Action::UploadPart, "only-this-bucket", Some("only-this-key")).await;
let denied_by_action =
authorize_operation(&session, &S3Action::GetObject, "only-this-bucket", Some("only-this-key")).await;
let denied_by_bucket =
authorize_operation(&session, &S3Action::UploadPart, "other-bucket", Some("only-this-key")).await;
(allowed, denied_by_action, denied_by_bucket)
},
)
.await;
assert!(result.0.is_ok());
assert!(matches!(result.1, Err(AuthorizationError::AccessDenied)));
assert!(matches!(result.2, Err(AuthorizationError::AccessDenied)));
}
/// Regression guard for the SECURITY invariant: the test override
/// is reachable only under cfg(test). The body depends on items in
/// the test_auth_override module, so if a future edit moves any of
/// those items out of a cfg(test) gate the build of THIS test
/// binary still succeeds (cfg(test) is active here) but the
/// reviewer recipe documented in test_auth_override's module
/// comment will start reporting matches in release expansion. Run
/// the recipe before shipping.
#[tokio::test]
async fn override_roundtrip_confirms_consult_path_under_cfg_test() {
let session = test_session();
// Without an installed override, consult returns None and the
// IAM path runs. IAM is not initialised in tests so the path
// returns IamUnavailable.
let without = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
assert!(matches!(without, Err(AuthorizationError::IamUnavailable)));
// With an installed override, consult returns Some and
// authorize_operation returns immediately with the override's
// decision, bypassing the IAM path.
let with = with_test_auth_override(|_, _, _| true, async {
authorize_operation(&session, &S3Action::GetObject, "b", None).await
})
.await;
assert!(with.is_ok());
// After the scope, consult returns None again and the IAM path
// reclaims the authorization decision.
let after = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
assert!(matches!(after, Err(AuthorizationError::IamUnavailable)));
}
/// IamUnavailable is distinct from AccessDenied at the gateway
/// boundary, so protocol drivers can branch on it. with_test_iam_unavailable
/// short-circuits authorize_operation with the IamUnavailable
/// variant regardless of any installed Allow/Deny override, and
/// the precedence is documented in test_auth_override::consult.
#[tokio::test]
async fn with_test_iam_unavailable_returns_iam_unavailable_variant() {
let session = test_session();
let result = with_test_iam_unavailable(authorize_operation(&session, &S3Action::GetObject, "b", Some("k"))).await;
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
}
/// IamUnavailable beats an installed Allow override, so a test
/// combining both flags exercises the documented precedence rule
/// in test_auth_override::consult: a degraded IAM is observed
/// before any policy evaluation.
#[tokio::test]
async fn with_test_iam_unavailable_takes_precedence_over_allow_override() {
let session = test_session();
let result = with_test_auth_override(
|_, _, _| true,
with_test_iam_unavailable(authorize_operation(&session, &S3Action::GetObject, "b", Some("k"))),
)
.await;
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
}
}