mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-05 12:57:42 +00:00
70a2441407
* refactor: route notify dispatch through app context * refactor: route admin IAM globals through app context (#3791) * refactor: centralize IAM root credential access (#3792)
585 lines
25 KiB
Rust
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)));
|
|
}
|
|
}
|