// 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 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 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 { 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) -> 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> = 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 = 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> { 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(decide: impl Fn(&S3Action, &str, Option<&str>) -> bool + 'static, body: Fut) -> R where Fut: std::future::Future, { 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(body: Fut) -> R where Fut: std::future::Future, { 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))); } }