mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-31 17:28:12 +00:00
feat(sftp): add SFTPv3 protocol support (#2875)
Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
@@ -24,8 +24,23 @@ 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
|
||||
@@ -211,16 +226,56 @@ pub fn is_operation_supported(protocol: super::session::Protocol, action: &S3Act
|
||||
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
|
||||
pub async fn is_authorized(session_context: &SessionContext, action: &S3Action, bucket: &str, object: Option<&str>) -> bool {
|
||||
/// 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!("IAM system unavailable: {}", e);
|
||||
return false;
|
||||
return Err(AuthorizationError::IamUnavailable);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,25 +307,273 @@ pub async fn is_authorized(session_context: &SessionContext, action: &S3Action,
|
||||
deny_only: false,
|
||||
};
|
||||
|
||||
iam_sys.is_allowed(&args).await
|
||||
Ok(iam_sys.is_allowed(&args).await)
|
||||
}
|
||||
|
||||
/// Authorize an operation and return an error if not authorized
|
||||
/// 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
|
||||
if is_authorized(session_context, action, bucket, object).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthorizationError::AccessDenied)
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user