feat(sftp): add SFTPv3 protocol support (#2875)

Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
escapecode
2026-05-10 04:48:42 +01:00
committed by GitHub
parent 8892cbbdd7
commit 96b293bf8a
44 changed files with 16555 additions and 155 deletions
+312 -9
View File
@@ -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)));
}
}