mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-28 09:08:58 +00:00
f403ed1c2e
* 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.
2088 lines
74 KiB
Rust
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¶m2=value2¶m3=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¶m3=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¶m2=value2¶m3=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¶m2=value2¶m3";
|
|
|
|
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¶m2=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");
|
|
}
|
|
}
|