Files
rustfs/rustfs/src/auth.rs
T
Zhengchao An f403ed1c2e feat(embedded): allow multiple embedded servers to coexist in one process
* feat(embedded): allow multiple embedded servers to coexist in one process

backlog#1052 S5: turn the embedded startup guard into a sequential lock
and make every write-once shared cell tolerate a second embedded start.
Two RustFSServers on different ports and volumes now start, run, and
shut down independently in the same process.

- EMBEDDED_SERVER_STARTED is released once each startup hands off; a
  second startup that runs after the first is no longer rejected.
- The second embedded server constructs its own InstanceContext instead
  of adopting the process bootstrap one, so region/endpoints/deployment
  id land on that context (the first server keeps adopting bootstrap to
  keep single-instance ambient facades unchanged).
- Startup-time tolerant paths:
  - action_credentials publish treats AlreadyInitialized as success
    (per-server ActionCredentialHandle already holds the real creds).
  - GLOBAL_RUSTFS_PORT warns instead of panicking on a second set.
  - Observability install returns Ok when the process subscriber is
    already set — the second server reuses it.
- New acceptance test proves two servers start, respond on their own
  ports, and one can be shut down without disturbing the other; the
  survivor keeps serving S3 requests. IAM and root-credential lookup
  still share a process domain (a second server whose creds differ from
  the first will fail signature validation), tracked as a follow-up.
- Embedded doc rewritten: 'Limitations' → 'Multi-instance status'; the
  AlreadyStarted error is now scoped to concurrent startups only.

The remaining work in #1052 is the auth path per-server dispatch and the
matching data-plane routing so two servers with different credentials
serve independent buckets end-to-end.

* feat(app): per-server auth and application context for multiple embedded servers (#4633)

backlog#1052 S6: each embedded server now authenticates against — and
its request path resolves — its OWN application context, so two servers
with different root credentials each accept their own access key and
reject the other's.

- AppContext is per-server: ensure_startup_after_iam constructs a fresh
  context around this server's store + IAM + KMS and installs it into the
  server's own ServerContextSlot, then publishes it as the process
  default first-writer-wins (publish_global_app_context) for legacy
  ambient readers. The old 'reuse the global if present' path is gone.
- FS::check (the S3 data-plane access gate) resolves auth against
  self.server_ctx's context: check_key_valid gains a _with_context
  variant that takes the root credentials and IAM system from an explicit
  context (None = ambient, unchanged for all 140+ existing callers). The
  region and the server context slot are published into the request
  extensions for downstream handlers.
- Each embedded server seeds its own root credentials into its context
  (ActionCredentialHandle.publish) at startup, so credential validation
  no longer falls back to the first server's process-global identity.
- The bucket/object/multipart use-cases resolve their store from the
  server's context (bucket_usecase_for/object_usecase_for/... take &FS).

New acceptance test: two servers with distinct credentials each
authenticate with their own key and reject the other's.

KNOWN FOLLOW-UP: full bucket-namespace isolation still requires threading
the instance context through the lower ecstore data plane (peer_sys /
disk registry / bucket-metadata reads still resolve via the process
GLOBAL_OBJECT_API), so the two servers do not yet present independent
bucket listings even though each holds its own store. That deeper pass —
a continuation of the #939 object-graph ctx threading — is the remaining
work on #1052.

Stacked on the S5 guard change.
2026-07-10 02:00:59 +08:00

2088 lines
74 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 crate::runtime_sources::{AppContext, current_action_credentials, current_ready_iam_handle};
use http::HeaderMap;
use http::Uri;
use rustfs_credentials::Credentials;
use rustfs_iam::error::Error as IamError;
use rustfs_iam::sys::{
SESSION_POLICY_NAME, get_claims_from_token_with_secret, get_claims_from_token_with_secret_allow_missing_exp,
};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use rustfs_trusted_proxies::ClientInfo;
use rustfs_utils::MaskedAccessKey;
use rustfs_utils::http::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER};
use s3s::S3Error;
use s3s::S3ErrorCode;
use s3s::S3Result;
use s3s::auth::S3Auth;
use s3s::auth::SecretKey;
use s3s::auth::SimpleAuth;
use s3s::s3_error;
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use subtle::ConstantTimeEq;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::{debug, trace, warn};
const LOG_COMPONENT_AUTH: &str = "auth";
const LOG_SUBSYSTEM_CREDENTIALS: &str = "credentials";
const LOG_SUBSYSTEM_KEYSTONE: &str = "keystone";
const LOG_SUBSYSTEM_REQUEST: &str = "request";
const EVENT_SECRET_KEY_LOOKUP_FAILED: &str = "secret_key_lookup_failed";
const EVENT_ACCESS_KEY_VALIDATION_STARTED: &str = "access_key_validation_started";
const EVENT_KEYSTONE_CREDENTIALS_DETECTED: &str = "keystone_credentials_detected";
const EVENT_KEYSTONE_CREDENTIALS_VALIDATED: &str = "keystone_credentials_validated";
const EVENT_KEYSTONE_CONTEXT_MISSING: &str = "keystone_context_missing";
const EVENT_SESSION_TOKEN_EXTRACTION: &str = "session_token_extraction";
/// Performs constant-time string comparison to prevent timing attacks.
///
/// This function should be used when comparing sensitive values like passwords,
/// API keys, or authentication tokens. It ensures the comparison time is
/// independent of the position where strings differ and handles length differences
/// securely.
///
/// # Security Note
/// This implementation uses the `subtle` crate to provide cryptographically
/// sound constant-time guarantees. The function is resistant to timing side-channel
/// attacks and suitable for security-critical comparisons.
///
/// # Example
/// ```
/// use rustfs::auth::constant_time_eq;
///
/// let secret1 = "my-secret-key";
/// let secret2 = "my-secret-key";
/// let secret3 = "wrong-secret";
///
/// assert!(constant_time_eq(secret1, secret2));
/// assert!(!constant_time_eq(secret1, secret3));
/// ```
pub fn constant_time_eq(a: &str, b: &str) -> bool {
a.as_bytes().ct_eq(b.as_bytes()).into()
}
// Authentication type constants
const JWT_ALGORITHM: &str = "Bearer ";
const SIGN_V2_ALGORITHM: &str = "AWS ";
const SIGN_V4_ALGORITHM: &str = "AWS4-HMAC-SHA256";
const STREAMING_CONTENT_SHA256: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD";
const STREAMING_CONTENT_SHA256_TRAILER: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER";
pub(crate) const UNSIGNED_PAYLOAD_TRAILER: &str = "STREAMING-UNSIGNED-PAYLOAD-TRAILER";
const ACTION_HEADER: &str = "Action";
const AMZ_CREDENTIAL: &str = "X-Amz-Credential";
const AMZ_ACCESS_KEY_ID: &str = "AWSAccessKeyId";
pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
// Authentication type enum
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AuthType {
#[default]
Unknown,
Anonymous,
Presigned,
PresignedV2,
PostPolicy,
StreamingSigned,
Signed,
SignedV2,
#[allow(clippy::upper_case_acronyms)]
JWT,
#[allow(clippy::upper_case_acronyms)]
STS,
StreamingSignedTrailer,
StreamingUnsignedTrailer,
}
pub struct IAMAuth {
simple_auth: SimpleAuth,
access_key: String,
secret_key: SecretKey,
}
impl Clone for IAMAuth {
fn clone(&self) -> Self {
Self {
simple_auth: SimpleAuth::from_single(self.access_key.clone(), self.secret_key.clone()),
access_key: self.access_key.clone(),
secret_key: self.secret_key.clone(),
}
}
}
impl IAMAuth {
pub fn new(ak: impl Into<String>, sk: impl Into<SecretKey>) -> Self {
let access_key = ak.into();
let secret_key = sk.into();
let simple_auth = SimpleAuth::from_single(access_key.clone(), secret_key.clone());
Self {
simple_auth,
access_key,
secret_key,
}
}
}
#[async_trait::async_trait]
impl S3Auth for IAMAuth {
async fn get_secret_key(&self, access_key: &str) -> S3Result<SecretKey> {
// NEW: Check if Keystone credentials are present in task-local storage
// This handles pure X-Auth-Token requests without Authorization header
use rustfs_keystone::KEYSTONE_CREDENTIALS;
if let Ok(Some(creds)) = KEYSTONE_CREDENTIALS.try_with(|c| c.clone()) {
debug!(
event = EVENT_KEYSTONE_CREDENTIALS_DETECTED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_KEYSTONE,
principal = %MaskedAccessKey(&creds.parent_user),
result = "token_auth",
"Keystone task-local credentials detected"
);
// Return empty secret key - Keystone uses token validation, not AWS signatures
return Ok(SecretKey::from(String::new()));
}
if access_key.is_empty() {
return Err(s3_error!(UnauthorizedAccess, "Your account is not signed up"));
}
// Check if this is a Keystone access key (from mixed auth scenario)
// Keystone credentials use token authentication, not signature verification
if access_key.starts_with("keystone:") {
debug!(
event = EVENT_KEYSTONE_CREDENTIALS_DETECTED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_KEYSTONE,
access_key = %MaskedAccessKey(access_key),
result = "token_auth",
"Keystone token-auth access key detected"
);
// Return empty secret key - Keystone uses token validation, not AWS signatures
// The actual credentials are stored in task-local storage by KeystoneAuthMiddleware
return Ok(SecretKey::from(String::new()));
}
if access_key == self.access_key {
return Ok(self.secret_key.clone());
}
if let Ok(key) = self.simple_auth.get_secret_key(access_key).await {
return Ok(key);
}
if let Ok(iam_store) = current_ready_iam_handle() {
// Use check_key instead of get_user to ensure user is loaded from disk if not in cache
// This is important for newly created users that may not be in cache yet.
// check_key will automatically attempt to load the user from disk if not found in cache.
match iam_store.check_key(access_key).await {
Ok((Some(id), _valid)) => {
// Return secret key for signature verification regardless of user status.
// Authorization will be checked separately in the authorization phase.
return Ok(SecretKey::from(id.credentials.secret_key));
}
Ok((None, _)) => {
warn!(
event = EVENT_SECRET_KEY_LOOKUP_FAILED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_CREDENTIALS,
access_key = %MaskedAccessKey(access_key),
reason = "no_such_user",
"Secret key lookup rejected"
);
}
Err(e) => {
warn!(
event = EVENT_SECRET_KEY_LOOKUP_FAILED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_CREDENTIALS,
access_key = %MaskedAccessKey(access_key),
error = ?e,
reason = "check_key_error",
"Secret key lookup errored"
);
return Err(iam_lookup_error_to_s3_error(&e));
}
}
} else {
warn!(
event = EVENT_SECRET_KEY_LOOKUP_FAILED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_CREDENTIALS,
access_key = %MaskedAccessKey(access_key),
reason = "iam_not_initialized",
"Secret key lookup skipped"
);
}
Err(s3_error!(
InvalidAccessKeyId,
"The Access Key Id you provided does not exist in our records."
))
}
}
fn iam_lookup_error_to_s3_error(_err: &IamError) -> S3Error {
s3_error!(InternalError, "IAM user lookup failed")
}
// check_key_valid checks the key is valid or not. return the user's credentials and if the user is the owner.
pub async fn check_key_valid(session_token: &str, access_key: &str) -> S3Result<(Credentials, bool)> {
check_key_valid_with_context(session_token, access_key, None).await
}
/// Validate an access key, resolving the root credentials and IAM system from
/// an explicit application context when one is given (backlog#1052 S6).
///
/// A per-server request path passes its own context so a second embedded
/// server authenticates against its own root identity and IAM domain instead
/// of the process defaults; `None` falls back to the ambient globals — the
/// single-instance legacy behavior that all existing callers keep.
pub async fn check_key_valid_with_context(
session_token: &str,
access_key: &str,
ctx: Option<&AppContext>,
) -> S3Result<(Credentials, bool)> {
// KEYSTONE INTEGRATION: Check if Keystone credentials are present in task-local storage
// This handles both:
// 1. Pure X-Auth-Token requests (access_key may be empty)
// 2. Keystone access keys formatted as "keystone:user_id"
use crate::auth_keystone;
use rustfs_keystone::KEYSTONE_CREDENTIALS;
// Try to get Keystone credentials from task-local storage first
// Add debug logging for UI authentication tracking
debug!(
event = EVENT_ACCESS_KEY_VALIDATION_STARTED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_CREDENTIALS,
access_key = %MaskedAccessKey(access_key),
has_session_token = !session_token.is_empty(),
"Access key validation started"
);
if let Ok(Some(credentials)) = KEYSTONE_CREDENTIALS.try_with(|creds| creds.clone()) {
debug!(
event = EVENT_KEYSTONE_CREDENTIALS_DETECTED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_KEYSTONE,
result = "task_local",
"Keystone task-local credentials detected"
);
if !auth_keystone::is_keystone_enabled() {
return Err(s3_error!(InvalidAccessKeyId, "Keystone authentication is not enabled"));
}
debug!(
event = EVENT_KEYSTONE_CREDENTIALS_VALIDATED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_KEYSTONE,
principal = %MaskedAccessKey(&credentials.parent_user),
has_project_name = credentials
.claims
.as_ref()
.and_then(|c| c.get("keystone_project_name"))
.and_then(|v| v.as_str())
.is_some(),
"Keystone task-local credentials validated"
);
// Determine if user is admin (owner-level access)
// Users with "admin" or "reseller_admin" role have owner permissions
// Roles are stored in claims["keystone_roles"] by the middleware
let is_owner = credentials
.claims
.as_ref()
.and_then(|claims| claims.get("keystone_roles"))
.and_then(|roles| roles.as_array())
.map(|roles| {
roles
.iter()
.any(|role| role.as_str().map(|r| r == "admin" || r == "reseller_admin").unwrap_or(false))
})
.unwrap_or(false);
debug!(
event = EVENT_KEYSTONE_CREDENTIALS_VALIDATED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_KEYSTONE,
principal = %MaskedAccessKey(&credentials.parent_user),
is_owner,
"Keystone owner permissions evaluated"
);
return Ok((credentials, is_owner));
}
// Legacy check for explicit "keystone:" prefix (for backwards compatibility)
if access_key.starts_with("keystone:") {
warn!(
event = EVENT_KEYSTONE_CONTEXT_MISSING,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_KEYSTONE,
access_key = %MaskedAccessKey(access_key),
"Keystone context missing for access key"
);
if !auth_keystone::is_keystone_enabled() {
return Err(s3_error!(InvalidAccessKeyId, "Keystone authentication is not enabled"));
}
return Err(s3_error!(InvalidAccessKeyId, "Keystone authentication requires X-Auth-Token header"));
}
// Prefer this server's context (backlog#1052 S6); fall back to the ambient
// process credentials when no context was threaded in.
let root_cred = match ctx {
Some(context) => context.action_credentials().get(),
None => current_action_credentials(),
};
let Some(mut cred) = root_cred else {
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
format!("get_global_action_cred {:?}", IamError::IamSysNotInitialized),
));
};
let sys_cred = cred.clone();
if !constant_time_eq(&cred.access_key, access_key) {
let iam_store = match ctx {
Some(context) if context.iam().is_ready() => Ok(context.iam().handle()),
Some(_) => Err(()),
None => current_ready_iam_handle().map_err(|_| ()),
};
let Ok(iam_store) = iam_store else {
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
format!("check_key_valid {:?}", IamError::IamSysNotInitialized),
));
};
let (u, ok) = iam_store
.check_key(access_key)
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("check claims failed1 {e}")))?;
if !ok {
let Some(ref u) = u else {
warn!(
event = EVENT_SECRET_KEY_LOOKUP_FAILED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_CREDENTIALS,
access_key = %MaskedAccessKey(access_key),
reason = "user_not_found",
"Access key validation rejected"
);
return Err(s3_error!(InvalidAccessKeyId, "check key failed"));
};
if u.credentials.status == "off" {
warn!(
event = EVENT_SECRET_KEY_LOOKUP_FAILED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_CREDENTIALS,
access_key = %MaskedAccessKey(access_key),
reason = "account_disabled",
"Access key validation rejected"
);
return Err(s3_error!(InvalidRequest, "ErrAccessKeyDisabled"));
}
warn!(
event = EVENT_SECRET_KEY_LOOKUP_FAILED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_CREDENTIALS,
access_key = %MaskedAccessKey(access_key),
reason = "validation_failed",
"Access key validation rejected"
);
return Err(s3_error!(InvalidRequest, "check key failed"));
}
let Some(u) = u else {
return Err(s3_error!(InvalidAccessKeyId, "check key failed"));
};
cred = u.credentials;
}
let claims = check_claims_from_token(session_token, &cred)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("check claims failed {e}")))?;
cred.claims = if !claims.is_empty() { Some(claims) } else { None };
let mut owner =
constant_time_eq(&sys_cred.access_key, &cred.access_key) || constant_time_eq(&cred.parent_user, &sys_cred.access_key);
// permitRootAccess
if let Some(claims) = &cred.claims
&& claims.contains_key(SESSION_POLICY_NAME)
{
owner = false
}
Ok((cred, owner))
}
pub fn check_claims_from_token(token: &str, cred: &Credentials) -> S3Result<HashMap<String, Value>> {
if !token.is_empty() && cred.access_key.is_empty() {
return Err(s3_error!(InvalidRequest, "no access key"));
}
if token.is_empty() && cred.is_temp() && !cred.is_service_account() {
return Err(s3_error!(InvalidRequest, "invalid token1"));
}
if !token.is_empty() && !cred.is_temp() {
return Err(s3_error!(InvalidRequest, "invalid token2"));
}
if !cred.is_service_account() && cred.is_temp() && token != cred.session_token {
return Err(s3_error!(InvalidRequest, "invalid token3"));
}
if cred.is_temp() && cred.is_expired() {
return Err(s3_error!(InvalidRequest, "invalid access key is temp and expired"));
}
let Some(sys_cred) = current_action_credentials() else {
return Err(s3_error!(InternalError, "action cred not init"));
};
let (token, secret) = if cred.is_service_account() {
(cred.session_token.as_str(), cred.secret_key.as_str())
} else {
(token, sys_cred.secret_key.as_str())
};
if !token.is_empty() {
let claims: HashMap<String, Value> = if cred.is_service_account() {
get_claims_from_token_with_secret_allow_missing_exp(token, secret)
.map_err(|_e| s3_error!(InvalidRequest, "invalid token"))?
} else {
get_claims_from_token_with_secret(token, secret).map_err(|_e| s3_error!(InvalidRequest, "invalid token"))?
};
return Ok(claims);
}
Ok(HashMap::new())
}
/// Check for Keystone authentication headers and authenticate if present
/// Returns Some((Credentials, is_owner)) if Keystone authentication succeeds
/// Returns None if no Keystone headers present (fall back to standard auth)
///
/// Reserved for future use (alternative Keystone auth path)
#[allow(dead_code)]
pub async fn try_keystone_auth(headers: &HeaderMap) -> S3Result<Option<(Credentials, bool)>> {
use crate::auth_keystone;
if !auth_keystone::is_keystone_enabled() {
return Ok(None);
}
match auth_keystone::authenticate_keystone(headers).await? {
Some(cred) => {
// Keystone credentials are never "owner" in the traditional sense
// unless they have admin role
let is_owner = cred
.groups
.as_ref()
.map(|groups| {
groups
.iter()
.any(|g| g.eq_ignore_ascii_case("admin") || g.eq_ignore_ascii_case("reseller_admin"))
})
.unwrap_or(false);
Ok(Some((cred, is_owner)))
}
None => Ok(None),
}
}
pub fn get_session_token<'a>(uri: &'a Uri, hds: &'a HeaderMap) -> Option<&'a str> {
let token = hds
.get("x-amz-security-token")
.map(|v| v.to_str().unwrap_or_default())
.or_else(|| get_query_param(uri.query().unwrap_or_default(), "x-amz-security-token"));
trace!(
event = EVENT_SESSION_TOKEN_EXTRACTION,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_REQUEST,
has_session_token = token.is_some(),
"Completed session token extraction"
);
token
}
pub(crate) fn extract_string_list_claim(claims: &HashMap<String, Value>, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(Value::Array(values)) => values.iter().filter_map(|v| v.as_str().map(ToOwned::to_owned)).collect(),
ClaimLookup::Found(Value::String(value)) => value
.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
fn policy_source_ip(remote_addr: Option<SocketAddr>, client_info: Option<&ClientInfo>) -> String {
client_info
.map(|info| info.real_ip.to_string())
.or_else(|| remote_addr.map(|addr| addr.ip().to_string()))
.unwrap_or_default()
}
fn policy_secure_transport(client_info: Option<&ClientInfo>) -> bool {
client_info
.and_then(|info| info.forwarded_proto.as_deref())
.map(|proto| proto.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
/// Get condition values for policy evaluation
///
/// # Arguments
/// * `header` - HTTP headers of the request
/// * `cred` - User credentials
/// * `version_id` - Optional version ID of the object
/// * `region` - Optional region/location constraint
/// * `remote_addr` - Optional remote address of the connection
///
/// # Returns
/// * `HashMap<String, Vec<String>>` - Condition values for policy evaluation
///
pub fn get_condition_values(
header: &HeaderMap,
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<SocketAddr>,
) -> HashMap<String, Vec<String>> {
get_condition_values_with_client_info(header, cred, version_id, region, remote_addr, None)
}
/// Get condition values for policy evaluation with verified client information.
pub fn get_condition_values_with_client_info(
header: &HeaderMap,
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<SocketAddr>,
client_info: Option<&ClientInfo>,
) -> HashMap<String, Vec<String>> {
get_condition_values_with_query_and_client_info(header, cred, version_id, region, remote_addr, None, client_info)
}
/// Get condition values for policy evaluation with optional query-string values.
///
/// # Arguments
/// * `header` - HTTP headers of the request
/// * `cred` - User credentials
/// * `version_id` - Optional version ID of the object
/// * `region` - Optional region/location constraint
/// * `remote_addr` - Optional remote address of the connection
/// * `query` - Optional request query string
///
/// # Returns
/// * `HashMap<String, Vec<String>>` - Condition values for policy evaluation
pub fn get_condition_values_with_query(
header: &HeaderMap,
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<SocketAddr>,
query: Option<&str>,
) -> HashMap<String, Vec<String>> {
get_condition_values_with_query_and_client_info(header, cred, version_id, region, remote_addr, query, None)
}
/// Get condition values for policy evaluation with optional query-string values
/// and verified client information from trusted proxy middleware.
pub fn get_condition_values_with_query_and_client_info(
header: &HeaderMap,
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<SocketAddr>,
query: Option<&str>,
client_info: Option<&ClientInfo>,
) -> HashMap<String, Vec<String>> {
let username = if cred.is_temp() || cred.is_service_account() {
cred.parent_user.clone()
} else {
cred.access_key.clone()
};
let sys_cred = current_action_credentials().unwrap_or_default();
let claims = &cred.claims;
let principal_type = if !username.is_empty() {
if claims.is_some() {
"AssumedRole"
} else if constant_time_eq(&sys_cred.access_key, &username) {
"Account"
} else {
"User"
}
} else {
"Anonymous"
};
// Get current time
let curr_time = OffsetDateTime::now_utc();
let epoch_time = curr_time.unix_timestamp();
// Use provided version ID or empty string
let vid = version_id.unwrap_or("");
// Determine auth type and signature version from headers and query
let (auth_type, signature_version) = determine_auth_type_and_version_with_query(header, query);
let is_tls = policy_secure_transport(client_info);
let source_ip = policy_source_ip(remote_addr, client_info);
let mut args = HashMap::new();
// Add basic time and security info
args.insert("CurrentTime".to_owned(), vec![curr_time.format(&Rfc3339).unwrap_or_default()]);
args.insert("EpochTime".to_owned(), vec![epoch_time.to_string()]);
args.insert("SecureTransport".to_owned(), vec![is_tls.to_string()]);
args.insert("SourceIp".to_owned(), vec![source_ip]);
// Add user agent and referer
if let Some(user_agent) = header.get("user-agent") {
args.insert("UserAgent".to_owned(), vec![user_agent.to_str().unwrap_or("").to_string()]);
}
if let Some(referer) = header.get("referer") {
args.insert("Referer".to_owned(), vec![referer.to_str().unwrap_or("").to_string()]);
}
// Add user and principal info
args.insert("userid".to_owned(), vec![username.clone()]);
args.insert("username".to_owned(), vec![username]);
args.insert("principaltype".to_owned(), vec![principal_type.to_string()]);
// Add version ID
if !vid.is_empty() {
args.insert("versionid".to_owned(), vec![vid.to_string()]);
}
// Add signature version and auth type
if !signature_version.is_empty() {
args.insert("signatureversion".to_owned(), vec![signature_version]);
}
if !auth_type.is_empty() {
args.insert("authType".to_owned(), vec![auth_type]);
}
if let Some(lc) = region
&& !lc.as_str().is_empty()
{
args.insert("LocationConstraint".to_owned(), vec![lc.to_string()]);
}
let mut clone_header = header.clone();
if let Some(v) = clone_header.get("x-amz-signature-age") {
args.insert("signatureAge".to_string(), vec![v.to_str().unwrap_or("").to_string()]);
clone_header.remove("x-amz-signature-age");
}
for obj_lock in &[
AMZ_OBJECT_LOCK_MODE_LOWER,
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
] {
let values = clone_header
.get_all(*obj_lock)
.iter()
.map(|v| v.to_str().unwrap_or("").to_string())
.collect::<Vec<String>>();
if !values.is_empty() {
args.insert(obj_lock.trim_start_matches("x-amz-").to_string(), values);
}
clone_header.remove(*obj_lock);
}
// S3 policy condition keys use "x-amz-grant-*" (policy key s3:x-amz-grant-* -> name() returns x-amz-grant-*)
for grant_header in &[
"x-amz-grant-full-control",
"x-amz-grant-read",
"x-amz-grant-write",
"x-amz-grant-read-acp",
"x-amz-grant-write-acp",
] {
let values = clone_header
.get_all(*grant_header)
.iter()
.map(|v| v.to_str().unwrap_or("").to_string())
.collect::<Vec<String>>();
if !values.is_empty() {
args.insert((*grant_header).to_string(), values);
}
clone_header.remove(*grant_header);
}
for (key, _values) in clone_header.iter() {
if key.as_str().eq_ignore_ascii_case("x-amz-tagging") {
continue;
}
if let Some(existing_values) = args.get_mut(key.as_str()) {
existing_values.extend(clone_header.get_all(key).iter().map(|v| v.to_str().unwrap_or("").to_string()));
} else {
args.insert(
key.as_str().to_string(),
header
.get_all(key)
.iter()
.map(|v| v.to_str().unwrap_or("").to_string())
.collect(),
);
}
}
if let Some(claims) = &cred.claims {
for (k, v) in claims {
if let Some(v_str) = v.as_str() {
args.insert(k.trim_start_matches("ldap").to_lowercase(), vec![v_str.to_string()]);
}
}
let grps = extract_string_list_claim(claims, "groups");
if !grps.is_empty() {
args.insert("groups".to_string(), grps);
}
let roles = extract_string_list_claim(claims, "roles");
if !roles.is_empty() {
args.insert("roles".to_string(), roles);
}
}
if let Some(groups) = &cred.groups
&& !args.contains_key("groups")
{
args.insert("groups".to_string(), groups.clone());
}
args
}
/// Get request authentication type
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `AuthType` - The determined authentication type
///
#[allow(dead_code)]
pub fn get_request_auth_type(header: &HeaderMap) -> AuthType {
get_request_auth_type_with_query(header, None)
}
#[allow(dead_code)]
pub(crate) fn get_request_auth_type_with_query(header: &HeaderMap, query: Option<&str>) -> AuthType {
if is_request_signature_v2(header) {
AuthType::SignedV2
} else if is_request_presigned_signature_v2(header, query) {
AuthType::PresignedV2
} else if is_request_sign_streaming_v4(header) {
AuthType::StreamingSigned
} else if is_request_sign_streaming_trailer_v4(header) {
AuthType::StreamingSignedTrailer
} else if is_request_unsigned_trailer_v4(header) {
AuthType::StreamingUnsignedTrailer
} else if is_request_signature_v4(header) {
AuthType::Signed
} else if is_request_presigned_signature_v4_with_query(header, query) {
AuthType::Presigned
} else if is_request_jwt(header) {
AuthType::JWT
} else if is_request_post_policy_signature_v4(header) {
AuthType::PostPolicy
} else if is_request_sts(header) {
AuthType::STS
} else if is_request_anonymous(header) {
AuthType::Anonymous
} else {
AuthType::Unknown
}
}
/// Helper function to determine auth type and signature version
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `(String, String)` - Tuple of auth type and signature version
///
#[allow(dead_code)]
fn determine_auth_type_and_version(header: &HeaderMap) -> (String, String) {
determine_auth_type_and_version_with_query(header, None)
}
#[allow(dead_code)]
fn determine_auth_type_and_version_with_query(header: &HeaderMap, query: Option<&str>) -> (String, String) {
match get_request_auth_type_with_query(header, query) {
AuthType::JWT => ("JWT".to_string(), String::new()),
AuthType::SignedV2 => ("REST-HEADER".to_string(), "AWS2".to_string()),
AuthType::PresignedV2 => ("REST-QUERY-STRING".to_string(), "AWS2".to_string()),
AuthType::StreamingSigned | AuthType::StreamingSignedTrailer | AuthType::StreamingUnsignedTrailer => {
("REST-HEADER".to_string(), "AWS4-HMAC-SHA256".to_string())
}
AuthType::Signed => ("REST-HEADER".to_string(), "AWS4-HMAC-SHA256".to_string()),
AuthType::Presigned => ("REST-QUERY-STRING".to_string(), "AWS4-HMAC-SHA256".to_string()),
AuthType::PostPolicy => ("POST".to_string(), String::new()),
AuthType::STS => ("STS".to_string(), String::new()),
AuthType::Anonymous => ("Anonymous".to_string(), String::new()),
AuthType::Unknown => (String::new(), String::new()),
}
}
/// Verify if request has JWT
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `bool` - True if request has JWT, false otherwise
fn is_request_jwt(header: &HeaderMap) -> bool {
if let Some(auth) = header.get("authorization")
&& let Ok(auth_str) = auth.to_str()
{
return auth_str.starts_with(JWT_ALGORITHM);
}
false
}
/// Verify if request has AWS Signature Version '4'
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `bool` - True if request has AWS Signature Version '4', false otherwise
fn is_request_signature_v4(header: &HeaderMap) -> bool {
if let Some(auth) = header.get("authorization")
&& let Ok(auth_str) = auth.to_str()
{
return auth_str.starts_with(SIGN_V4_ALGORITHM);
}
false
}
/// Verify if request has AWS Signature Version '2'
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `bool` - True if request has AWS Signature Version '2', false otherwise
fn is_request_signature_v2(header: &HeaderMap) -> bool {
if let Some(auth) = header.get("authorization")
&& let Ok(auth_str) = auth.to_str()
{
return !auth_str.starts_with(SIGN_V4_ALGORITHM) && auth_str.starts_with(SIGN_V2_ALGORITHM);
}
false
}
/// Verify if request has AWS PreSign Version '4'
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `bool` - True if request has AWS PreSign Version '4', false otherwise
#[allow(dead_code)]
pub(crate) fn is_request_presigned_signature_v4(header: &HeaderMap) -> bool {
is_request_presigned_signature_v4_with_query(header, None)
}
pub(crate) fn is_request_presigned_signature_v4_with_query(header: &HeaderMap, query: Option<&str>) -> bool {
if let Some(credential) = header.get(AMZ_CREDENTIAL) {
return !credential.to_str().unwrap_or("").is_empty();
}
query
.and_then(|query| get_query_param(query, "x-amz-credential"))
.is_some_and(|credential| !credential.is_empty())
}
/// Verify request has AWS PreSign Version '2'
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `bool` - True if request has AWS PreSign Version '2', false otherwise
fn is_request_presigned_signature_v2(header: &HeaderMap, query: Option<&str>) -> bool {
if let Some(access_key) = header.get(AMZ_ACCESS_KEY_ID) {
return !access_key.to_str().unwrap_or("").is_empty();
}
query
.and_then(|query| get_query_param(query, "awsaccesskeyid"))
.is_some_and(|access_key| !access_key.is_empty())
}
/// Verify if request has AWS Post policy Signature Version '4'
///
/// # Arguments
/// * `header` - HTTP headers of the request
///
/// # Returns
/// * `bool` - True if request has AWS Post policy Signature Version '4', false otherwise
fn is_request_post_policy_signature_v4(header: &HeaderMap) -> bool {
if let Some(content_type) = header.get("content-type")
&& let Ok(ct) = content_type.to_str()
{
return ct.contains("multipart/form-data");
}
false
}
/// Verify if the request has AWS Streaming Signature Version '4'
fn is_request_sign_streaming_v4(header: &HeaderMap) -> bool {
if let Some(content_sha256) = header.get("x-amz-content-sha256")
&& let Ok(sha256_str) = content_sha256.to_str()
{
return sha256_str == STREAMING_CONTENT_SHA256;
}
false
}
// Verify if the request has AWS Streaming Signature Version '4' with trailer
fn is_request_sign_streaming_trailer_v4(header: &HeaderMap) -> bool {
if let Some(content_sha256) = header.get("x-amz-content-sha256")
&& let Ok(sha256_str) = content_sha256.to_str()
{
return sha256_str == STREAMING_CONTENT_SHA256_TRAILER;
}
false
}
// Verify if the request has AWS Streaming Signature Version '4' with unsigned content and trailer
fn is_request_unsigned_trailer_v4(header: &HeaderMap) -> bool {
if let Some(content_sha256) = header.get("x-amz-content-sha256")
&& let Ok(sha256_str) = content_sha256.to_str()
{
return sha256_str == UNSIGNED_PAYLOAD_TRAILER;
}
false
}
// Verify if request is STS (Security Token Service)
fn is_request_sts(header: &HeaderMap) -> bool {
if let Some(action) = header.get(ACTION_HEADER) {
return !action.to_str().unwrap_or("").is_empty();
}
false
}
// Verify if request is anonymous
fn is_request_anonymous(header: &HeaderMap) -> bool {
header.get("authorization").is_none()
}
pub fn get_query_param<'a>(query: &'a str, param_name: &str) -> Option<&'a str> {
let param_name = param_name.to_lowercase();
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if let (Some(key), Some(value)) = (parts.next(), parts.next())
&& key.to_lowercase() == param_name
{
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use http::{HeaderMap, HeaderValue, Uri};
use rustfs_credentials::Credentials;
use rustfs_trusted_proxies::ValidationMode;
use s3s::auth::SecretKey;
use serde_json::json;
use std::collections::HashMap;
use time::OffsetDateTime;
fn create_test_credentials() -> Credentials {
Credentials {
access_key: "test-access-key".to_string(),
secret_key: "test-secret-key".to_string(),
session_token: "".to_string(),
expiration: None,
status: "on".to_string(),
parent_user: "".to_string(),
groups: None,
claims: None,
name: Some("test-user".to_string()),
description: Some("test user for auth tests".to_string()),
}
}
fn create_temp_credentials() -> Credentials {
Credentials {
access_key: "temp-access-key".to_string(),
secret_key: "temp-secret-key".to_string(),
session_token: "temp-session-token".to_string(),
expiration: Some(OffsetDateTime::now_utc() + time::Duration::hours(1)),
status: "on".to_string(),
parent_user: "parent-user".to_string(),
groups: Some(vec!["test-group".to_string()]),
claims: None,
name: Some("temp-user".to_string()),
description: Some("temporary user for auth tests".to_string()),
}
}
fn create_service_account_credentials() -> Credentials {
let mut claims = HashMap::new();
claims.insert(rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA.to_string(), json!("test-policy"));
Credentials {
access_key: "service-access-key".to_string(),
secret_key: "service-secret-key".to_string(),
session_token: "service-session-token".to_string(),
expiration: None,
status: "on".to_string(),
parent_user: "service-parent".to_string(),
groups: None,
claims: Some(claims),
name: Some("service-account".to_string()),
description: Some("service account for auth tests".to_string()),
}
}
#[test]
fn test_iam_auth_creation() {
let access_key = "test-access-key";
let secret_key = SecretKey::from("test-secret-key");
let iam_auth = IAMAuth::new(access_key, secret_key);
// The struct should be created successfully
// We can't easily test internal state without exposing it,
// but we can test it doesn't panic on creation
assert_eq!(size_of_val(&iam_auth), size_of::<IAMAuth>());
}
#[tokio::test]
async fn test_iam_auth_clone_preserves_bootstrap_secret() {
let iam_auth = IAMAuth::new("test-ak", SecretKey::from("test-sk"));
let cloned = iam_auth.clone();
let secret = cloned.get_secret_key("test-ak").await;
assert!(secret.is_ok());
}
#[tokio::test]
async fn test_iam_auth_get_secret_key_empty_access_key() {
let iam_auth = IAMAuth::new("test-ak", SecretKey::from("test-sk"));
let result = iam_auth.get_secret_key("").await;
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.code(), &S3ErrorCode::UnauthorizedAccess);
assert!(error.message().unwrap_or("").contains("Your account is not signed up"));
}
#[test]
fn test_iam_lookup_error_maps_to_internal_error() {
let result = iam_lookup_error_to_s3_error(&IamError::Io(std::io::Error::other("load user failed")));
assert_eq!(result.code(), &S3ErrorCode::InternalError);
assert_eq!(result.message(), Some("IAM user lookup failed"));
}
#[test]
fn test_check_claims_from_token_empty_token_and_access_key() {
let mut cred = create_test_credentials();
cred.access_key = "".to_string();
let result = check_claims_from_token("test-token", &cred);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
assert!(error.message().unwrap_or("").contains("no access key"));
}
#[test]
fn test_check_claims_from_token_temp_credentials_without_token() {
let mut cred = create_temp_credentials();
// Make it non-service account
cred.claims = None;
let result = check_claims_from_token("", &cred);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
assert!(error.message().unwrap_or("").contains("invalid token1"));
}
#[test]
fn test_check_claims_from_token_non_temp_with_token() {
let mut cred = create_test_credentials();
cred.session_token = "".to_string(); // Make it non-temp
let result = check_claims_from_token("some-token", &cred);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
assert!(error.message().unwrap_or("").contains("invalid token2"));
}
#[test]
fn test_check_claims_from_token_mismatched_session_token() {
let mut cred = create_temp_credentials();
// Make sure it's not a service account
cred.claims = None;
let result = check_claims_from_token("wrong-session-token", &cred);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
assert!(error.message().unwrap_or("").contains("invalid token3"));
}
#[test]
fn test_check_claims_from_token_expired_credentials() {
let mut cred = create_temp_credentials();
cred.expiration = Some(OffsetDateTime::now_utc() - time::Duration::hours(1)); // Expired
cred.claims = None; // Make sure it's not a service account
let result = check_claims_from_token(&cred.session_token, &cred);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
// The function checks various conditions in order. An expired temp credential
// might trigger other validation errors first (like token mismatch)
let msg = error.message().unwrap_or("");
let is_valid_error = msg.contains("invalid access key is temp and expired")
|| msg.contains("invalid token")
|| msg.contains("action cred not init");
assert!(is_valid_error, "Unexpected error message: '{msg}'");
}
#[test]
fn test_check_claims_from_token_valid_non_temp_credentials() {
let mut cred = create_test_credentials();
cred.session_token = "".to_string(); // Make it non-temp
let result = check_claims_from_token("", &cred);
// This might fail due to global state dependencies, but should return error about global cred init
if let Ok(claims) = result {
assert!(claims.is_empty());
} else if let Err(error) = result {
assert_eq!(error.code(), &S3ErrorCode::InternalError);
assert!(error.message().unwrap_or("").contains("action cred not init"));
}
}
#[test]
fn test_get_session_token_from_header() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-security-token", HeaderValue::from_static("test-session-token"));
let uri: Uri = "https://example.com/".parse().unwrap();
let token = get_session_token(&uri, &headers);
assert_eq!(token, Some("test-session-token"));
}
#[test]
fn test_get_session_token_from_query_param() {
let headers = HeaderMap::new();
let uri: Uri = "https://example.com/?x-amz-security-token=query-session-token"
.parse()
.unwrap();
let token = get_session_token(&uri, &headers);
assert_eq!(token, Some("query-session-token"));
}
#[test]
fn test_get_session_token_header_takes_precedence() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-security-token", HeaderValue::from_static("header-token"));
let uri: Uri = "https://example.com/?x-amz-security-token=query-token".parse().unwrap();
let token = get_session_token(&uri, &headers);
assert_eq!(token, Some("header-token"));
}
#[test]
fn test_get_session_token_no_token() {
let headers = HeaderMap::new();
let uri: Uri = "https://example.com/".parse().unwrap();
let token = get_session_token(&uri, &headers);
assert_eq!(token, None);
}
#[test]
fn test_get_condition_values_regular_user() {
let cred = create_test_credentials();
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("userid"), Some(&vec!["test-access-key".to_string()]));
assert_eq!(conditions.get("username"), Some(&vec!["test-access-key".to_string()]));
assert_eq!(conditions.get("principaltype"), Some(&vec!["User".to_string()]));
}
#[test]
fn test_get_condition_values_with_presigned_query() {
let cred = create_test_credentials();
let headers = HeaderMap::new();
let uri: Uri = "https://example.com/?X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request"
.parse()
.unwrap();
let conditions = get_condition_values_with_query(&headers, &cred, None, None, None, uri.query());
assert_eq!(conditions.get("signatureversion"), Some(&vec!["AWS4-HMAC-SHA256".to_string()]));
assert_eq!(conditions.get("authType"), Some(&vec!["REST-QUERY-STRING".to_string()]));
}
#[test]
fn test_get_condition_values_temp_user() {
let cred = create_temp_credentials();
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("userid"), Some(&vec!["parent-user".to_string()]));
assert_eq!(conditions.get("username"), Some(&vec!["parent-user".to_string()]));
assert_eq!(conditions.get("principaltype"), Some(&vec!["User".to_string()]));
}
#[test]
fn test_get_condition_values_service_account() {
let cred = create_service_account_credentials();
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("userid"), Some(&vec!["service-parent".to_string()]));
assert_eq!(conditions.get("username"), Some(&vec!["service-parent".to_string()]));
// Service accounts with claims should be "AssumedRole" type
assert_eq!(conditions.get("principaltype"), Some(&vec!["AssumedRole".to_string()]));
}
#[test]
fn test_get_condition_values_with_object_lock_headers() {
let cred = create_test_credentials();
let mut headers = HeaderMap::new();
headers.insert(AMZ_OBJECT_LOCK_MODE_LOWER, HeaderValue::from_static("GOVERNANCE"));
headers.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, HeaderValue::from_static("2024-12-31T23:59:59Z"));
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("object-lock-mode"), Some(&vec!["GOVERNANCE".to_string()]));
assert_eq!(
conditions.get("object-lock-retain-until-date"),
Some(&vec!["2024-12-31T23:59:59Z".to_string()])
);
}
#[test]
fn test_get_condition_values_with_grant_headers() {
let cred = create_test_credentials();
let mut headers = HeaderMap::new();
headers.insert("x-amz-grant-full-control", HeaderValue::from_static("id=owner-123"));
headers.insert(
"x-amz-grant-read",
HeaderValue::from_static("uri=http://acs.amazonaws.com/groups/global/AllUsers"),
);
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("x-amz-grant-full-control"), Some(&vec!["id=owner-123".to_string()]));
assert_eq!(
conditions.get("x-amz-grant-read"),
Some(&vec!["uri=http://acs.amazonaws.com/groups/global/AllUsers".to_string()])
);
}
#[test]
fn test_get_condition_values_with_signature_age() {
let cred = create_test_credentials();
let mut headers = HeaderMap::new();
headers.insert("x-amz-signature-age", HeaderValue::from_static("300"));
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("signatureAge"), Some(&vec!["300".to_string()]));
// Verify the header is removed after processing
// (we can't directly test this without changing the function signature)
}
#[test]
fn test_get_condition_values_with_claims() {
let mut cred = create_service_account_credentials();
let mut claims = HashMap::new();
claims.insert("ldapUsername".to_string(), json!("ldap-user"));
claims.insert("groups".to_string(), json!(["group1", "group2"]));
cred.claims = Some(claims);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("username"), Some(&vec!["ldap-user".to_string()]));
assert_eq!(conditions.get("groups"), Some(&vec!["group1".to_string(), "group2".to_string()]));
}
#[test]
fn test_get_condition_values_with_roles_claim_array() {
let mut cred = create_service_account_credentials();
let mut claims = HashMap::new();
claims.insert("roles".to_string(), json!(["role1", "role2"]));
cred.claims = Some(claims);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("roles"), Some(&vec!["role1".to_string(), "role2".to_string()]));
}
#[test]
fn test_get_condition_values_with_roles_claim_csv_and_case_insensitive() {
let mut cred = create_service_account_credentials();
let mut claims = HashMap::new();
claims.insert("Roles".to_string(), json!("role1, role2"));
cred.claims = Some(claims);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("roles"), Some(&vec!["role1".to_string(), "role2".to_string()]));
}
#[test]
fn test_get_condition_values_with_roles_claim_ambiguous_case_insensitive_match_returns_empty() {
let mut cred = create_service_account_credentials();
let mut claims = HashMap::new();
claims.insert("Roles".to_string(), json!(["role1"]));
claims.insert("ROLES".to_string(), json!(["role2"]));
cred.claims = Some(claims);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("roles"), None);
}
#[test]
fn test_get_condition_values_with_credential_groups() {
let mut cred = create_test_credentials();
cred.groups = Some(vec!["cred-group1".to_string(), "cred-group2".to_string()]);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(
conditions.get("groups"),
Some(&vec!["cred-group1".to_string(), "cred-group2".to_string()])
);
}
#[test]
fn test_get_query_param_found() {
let query = "param1=value1&param2=value2&param3=value3";
let result = get_query_param(query, "param2");
assert_eq!(result, Some("value2"));
}
#[test]
fn test_get_query_param_case_insensitive() {
let query = "Param1=value1&PARAM2=value2&param3=value3";
let result = get_query_param(query, "param2");
assert_eq!(result, Some("value2"));
}
#[test]
fn test_get_query_param_not_found() {
let query = "param1=value1&param2=value2&param3=value3";
let result = get_query_param(query, "param4");
assert_eq!(result, None);
}
#[test]
fn test_get_query_param_empty_query() {
let query = "";
let result = get_query_param(query, "param1");
assert_eq!(result, None);
}
#[test]
fn test_get_query_param_malformed_query() {
let query = "param1&param2=value2&param3";
let result = get_query_param(query, "param2");
assert_eq!(result, Some("value2"));
let result = get_query_param(query, "param1");
assert_eq!(result, None);
}
#[test]
fn test_get_query_param_with_equals_in_value() {
let query = "param1=value=with=equals&param2=value2";
let result = get_query_param(query, "param1");
assert_eq!(result, Some("value=with=equals"));
}
#[test]
fn test_credentials_is_expired() {
let mut cred = create_test_credentials();
cred.expiration = Some(OffsetDateTime::now_utc() - time::Duration::hours(1));
assert!(cred.is_expired());
}
#[test]
fn test_credentials_is_not_expired() {
let mut cred = create_test_credentials();
cred.expiration = Some(OffsetDateTime::now_utc() + time::Duration::hours(1));
assert!(!cred.is_expired());
}
#[test]
fn test_credentials_no_expiration() {
let cred = create_test_credentials();
assert!(!cred.is_expired());
}
#[test]
fn test_credentials_is_temp() {
let cred = create_temp_credentials();
assert!(cred.is_temp());
}
#[test]
fn test_credentials_is_not_temp_no_session_token() {
let mut cred = create_test_credentials();
cred.session_token = "".to_string();
assert!(!cred.is_temp());
}
#[test]
fn test_credentials_is_not_temp_expired() {
let mut cred = create_temp_credentials();
cred.expiration = Some(OffsetDateTime::now_utc() - time::Duration::hours(1));
assert!(!cred.is_temp());
}
#[test]
fn test_credentials_is_service_account() {
let cred = create_service_account_credentials();
assert!(cred.is_service_account());
}
#[test]
fn test_credentials_is_not_service_account() {
let cred = create_test_credentials();
assert!(!cred.is_service_account());
}
#[test]
fn test_get_request_auth_type_jwt() {
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"));
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::JWT);
}
#[test]
fn test_get_request_auth_type_signature_v2() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("AWS AKIAIOSFODNN7EXAMPLE:frJIUN8DYpKDtOLCwo//bqJZQ1iY="),
);
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::SignedV2);
}
#[test]
fn test_get_request_auth_type_signature_v4() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"),
);
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::Signed);
}
#[test]
fn test_get_request_auth_type_presigned_v2() {
let mut headers = HeaderMap::new();
headers.insert("AWSAccessKeyId", HeaderValue::from_static("AKIAIOSFODNN7EXAMPLE"));
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::PresignedV2);
}
#[test]
fn test_get_request_auth_type_presigned_v2_from_query() {
let headers = HeaderMap::new();
let uri: Uri = "https://example.com/?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Signature=example&Expires=1672531200"
.parse()
.unwrap();
let auth_type = get_request_auth_type_with_query(&headers, uri.query());
assert_eq!(auth_type, AuthType::PresignedV2);
}
#[test]
fn test_get_request_auth_type_presigned_v4() {
let mut headers = HeaderMap::new();
headers.insert(
"X-Amz-Credential",
HeaderValue::from_static("AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"),
);
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::Presigned);
}
#[test]
fn test_get_request_auth_type_presigned_v4_from_query() {
let headers = HeaderMap::new();
let uri: Uri = "https://example.com/?X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request"
.parse()
.unwrap();
let auth_type = get_request_auth_type_with_query(&headers, uri.query());
assert_eq!(auth_type, AuthType::Presigned);
}
#[test]
fn test_get_request_auth_type_post_policy() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"),
);
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::PostPolicy);
}
#[test]
fn test_get_request_auth_type_streaming_signed() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-content-sha256", HeaderValue::from_static("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"));
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::StreamingSigned);
}
#[test]
fn test_get_request_auth_type_streaming_signed_trailer() {
let mut headers = HeaderMap::new();
headers.insert(
"x-amz-content-sha256",
HeaderValue::from_static("STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER"),
);
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::StreamingSignedTrailer);
}
#[test]
fn test_get_request_auth_type_streaming_unsigned_trailer() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-content-sha256", HeaderValue::from_static("STREAMING-UNSIGNED-PAYLOAD-TRAILER"));
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::StreamingUnsignedTrailer);
}
#[test]
fn test_get_request_auth_type_sts() {
let mut headers = HeaderMap::new();
headers.insert("Action", HeaderValue::from_static("AssumeRole"));
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::STS);
}
#[test]
fn test_get_request_auth_type_anonymous() {
let headers = HeaderMap::new();
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::Anonymous);
}
#[test]
fn test_get_request_auth_type_unknown() {
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("CustomAuth token123"));
let auth_type = get_request_auth_type(&headers);
assert_eq!(auth_type, AuthType::Unknown);
}
#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq("test", "test"));
assert!(!constant_time_eq("test", "Test"));
assert!(!constant_time_eq("test", "test1"));
assert!(!constant_time_eq("test1", "test"));
assert!(!constant_time_eq("", "test"));
assert!(constant_time_eq("", ""));
// Test with credentials-like strings
let key1 = "AKIAIOSFODNN7EXAMPLE";
let key2 = "AKIAIOSFODNN7EXAMPLE";
let key3 = "AKIAIOSFODNN7EXAMPLF";
assert!(constant_time_eq(key1, key2));
assert!(!constant_time_eq(key1, key3));
}
#[test]
fn test_get_condition_values_source_ip() {
let mut headers = HeaderMap::new();
let cred = Credentials::default();
// Case 1: No headers, no remote addr -> empty string
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("SourceIp").unwrap()[0], "");
// Case 2: No headers, with remote addr -> remote addr
let remote_addr: std::net::SocketAddr = "192.168.0.10:12345".parse().unwrap();
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 3: X-Forwarded-For is ignored without verified proxy context
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 4: X-Forwarded-For with multiple IPs is ignored without verified proxy context
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.3, 10.0.0.4"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 5: X-Real-IP is ignored without verified proxy context
headers.remove("x-forwarded-for");
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.2"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 6: Forwarded is ignored without verified proxy context
headers.remove("x-real-ip");
headers.insert("forwarded", HeaderValue::from_static("for=10.0.0.5;proto=http"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 7: Forwarded with quotes and multiple values is ignored without verified proxy context
headers.insert("forwarded", HeaderValue::from_static("for=\"10.0.0.6\", for=10.0.0.7"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 8: IPv6 Remote Addr
let remote_addr_v6: std::net::SocketAddr = "[2001:db8::1]:8080".parse().unwrap();
headers.clear();
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr_v6));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "2001:db8::1");
}
#[test]
fn test_get_condition_values_uses_verified_client_info() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
let cred = Credentials::default();
let remote_addr: std::net::SocketAddr = "192.168.0.10:12345".parse().unwrap();
let client_info = ClientInfo::from_trusted_proxy(
"10.0.0.1".parse().unwrap(),
None,
Some("https".to_string()),
"192.168.0.10".parse().unwrap(),
1,
ValidationMode::Lenient,
Vec::new(),
);
let conditions =
get_condition_values_with_client_info(&headers, &cred, None, None, Some(remote_addr), Some(&client_info));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "10.0.0.1");
assert_eq!(conditions.get("SecureTransport").unwrap()[0], "true");
}
#[test]
fn test_get_condition_values_ignores_unverified_secure_transport_header() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
let cred = Credentials::default();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("SecureTransport").unwrap()[0], "false");
}
// ========== KEYSTONE AUTHENTICATION TESTS ==========
#[tokio::test]
async fn test_check_key_valid_keystone_not_enabled() {
// Test that keystone: access key fails when Keystone is not enabled
let result = check_key_valid("dummy-token", "keystone:user123").await;
// Should fail with InvalidAccessKeyId because Keystone is not enabled
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(*err.code(), s3s::S3ErrorCode::InvalidAccessKeyId);
}
#[tokio::test]
async fn test_check_key_valid_keystone_no_credentials() {
use rustfs_keystone::KEYSTONE_CREDENTIALS;
// Test behavior when Keystone would be enabled but no credentials in task-local
// This simulates a request that bypassed middleware
KEYSTONE_CREDENTIALS
.scope(None, async {
// Call function that checks for keystone: prefix
// In real scenario, would check is_keystone_enabled() first
let access_key = "keystone:user123";
if access_key.starts_with("keystone:") {
// Without credentials in task-local, this should fail
let creds_result = KEYSTONE_CREDENTIALS.try_with(|c: &Option<Credentials>| c.clone());
assert!(creds_result.is_ok()); // try_with succeeds
assert!(creds_result.unwrap().is_none()); // but value is None
}
})
.await;
}
#[test]
fn test_keystone_role_detection_admin() {
// Test role detection logic for admin role
let mut claims: HashMap<String, serde_json::Value> = HashMap::new();
claims.insert("roles".to_string(), json!(["admin", "member"]));
let is_owner = claims
.get("roles")
.and_then(|roles| roles.as_array())
.map(|roles| {
roles
.iter()
.any(|role| role.as_str().map(|r| r == "admin" || r == "reseller_admin").unwrap_or(false))
})
.unwrap_or(false);
assert!(is_owner);
}
#[test]
fn test_keystone_role_detection_reseller_admin() {
// Test role detection logic for reseller_admin role
let mut claims: HashMap<String, serde_json::Value> = HashMap::new();
claims.insert("roles".to_string(), json!(["reseller_admin"]));
let is_owner = claims
.get("roles")
.and_then(|roles| roles.as_array())
.map(|roles| {
roles
.iter()
.any(|role| role.as_str().map(|r| r == "admin" || r == "reseller_admin").unwrap_or(false))
})
.unwrap_or(false);
assert!(is_owner);
}
#[test]
fn test_keystone_role_detection_non_admin() {
// Test role detection logic for non-admin roles
let mut claims: HashMap<String, serde_json::Value> = HashMap::new();
claims.insert("roles".to_string(), json!(["member", "reader"]));
let is_owner = claims
.get("roles")
.and_then(|roles| roles.as_array())
.map(|roles| {
roles
.iter()
.any(|role| role.as_str().map(|r| r == "admin" || r == "reseller_admin").unwrap_or(false))
})
.unwrap_or(false);
assert!(!is_owner);
}
#[test]
fn test_keystone_role_detection_empty() {
// Test role detection logic for empty roles
let mut claims: HashMap<String, serde_json::Value> = HashMap::new();
claims.insert("roles".to_string(), json!([]));
let is_owner = claims
.get("roles")
.and_then(|roles| roles.as_array())
.map(|roles| {
roles
.iter()
.any(|role| role.as_str().map(|r| r == "admin" || r == "reseller_admin").unwrap_or(false))
})
.unwrap_or(false);
assert!(!is_owner);
}
#[test]
fn test_keystone_role_detection_no_claim() {
// Test role detection logic when roles claim is missing
let claims: HashMap<String, serde_json::Value> = HashMap::new();
let is_owner = claims
.get("roles")
.and_then(|roles| roles.as_array())
.map(|roles| {
roles
.iter()
.any(|role| role.as_str().map(|r| r == "admin" || r == "reseller_admin").unwrap_or(false))
})
.unwrap_or(false);
assert!(!is_owner);
}
#[tokio::test]
async fn test_keystone_task_local_storage() {
use rustfs_keystone::KEYSTONE_CREDENTIALS;
// Test that task-local storage properly stores and retrieves credentials
let mut claims = HashMap::new();
claims.insert("project_id".to_string(), json!("project123"));
claims.insert("roles".to_string(), json!(["member"]));
let test_creds = Credentials {
access_key: "keystone:testuser".to_string(),
secret_key: String::new(),
session_token: String::new(),
expiration: None,
status: "on".to_string(),
parent_user: "testuser".to_string(),
groups: None,
claims: Some(claims),
name: Some("Test User".to_string()),
description: None,
};
// Outside scope, should fail
let result = KEYSTONE_CREDENTIALS.try_with(|c: &Option<Credentials>| c.clone());
assert!(result.is_err());
// Inside scope, should succeed
KEYSTONE_CREDENTIALS
.scope(Some(test_creds.clone()), async {
let result = KEYSTONE_CREDENTIALS.try_with(|c: &Option<Credentials>| c.clone());
assert!(result.is_ok());
let creds = result.unwrap();
assert!(creds.is_some());
assert_eq!(creds.unwrap().access_key, "keystone:testuser");
})
.await;
// After scope, should fail again
let result = KEYSTONE_CREDENTIALS.try_with(|c: &Option<Credentials>| c.clone());
assert!(result.is_err());
}
}
#[cfg(test)]
mod tests_policy {
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_policy::policy::{Args, BucketPolicy, BucketPolicyArgs, Policy};
use std::collections::HashMap;
#[tokio::test]
async fn test_iam_policy_source_ip() {
let policy_json = r#"{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::mybucket/*"],
"Condition": {
"IpAddress": {
"aws:SourceIp": "192.168.1.0/24"
}
}
}
]
}"#;
let policy: Policy = serde_json::from_str(policy_json).expect("Failed to parse IAM policy");
// Case 1: Matching IP
let mut conditions = HashMap::new();
conditions.insert("SourceIp".to_string(), vec!["192.168.1.10".to_string()]);
let claims = HashMap::new();
let args = Args {
account: "test-account",
groups: &None,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "mybucket",
conditions: &conditions,
is_owner: false,
object: "myobject",
claims: &claims,
deny_only: false,
};
assert!(policy.is_allowed(&args).await, "IAM Policy should allow matching IP");
// Case 2: Non-matching IP
let mut conditions_fail = HashMap::new();
conditions_fail.insert("SourceIp".to_string(), vec!["10.0.0.1".to_string()]);
let args_fail = Args {
conditions: &conditions_fail,
..args
};
assert!(!policy.is_allowed(&args_fail).await, "IAM Policy should deny non-matching IP");
}
#[tokio::test]
async fn test_bucket_policy_source_ip() {
let policy_json = r#"{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::mybucket/*"],
"Condition": {
"IpAddress": {
"aws:SourceIp": "192.168.1.0/24"
}
}
}
]
}"#;
let policy: BucketPolicy = serde_json::from_str(policy_json).expect("Failed to parse Bucket policy");
// Case 1: Matching IP
let mut conditions = HashMap::new();
conditions.insert("SourceIp".to_string(), vec!["192.168.1.10".to_string()]);
let args = BucketPolicyArgs {
account: "test-account",
groups: &None,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "mybucket",
conditions: &conditions,
is_owner: false,
object: "myobject",
};
assert!(policy.is_allowed(&args).await, "Bucket Policy should allow matching IP");
// Case 2: Non-matching IP
let mut conditions_fail = HashMap::new();
conditions_fail.insert("SourceIp".to_string(), vec!["10.0.0.1".to_string()]);
let args_fail = BucketPolicyArgs {
conditions: &conditions_fail,
..args
};
assert!(!policy.is_allowed(&args_fail).await, "Bucket Policy should deny non-matching IP");
}
}