mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-28 00:58:59 +00:00
fix(iam): preserve portable IAM storage and derived auth (#2713)
This commit is contained in:
@@ -645,6 +645,20 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
let sts_accounts = self.cache.sts_accounts.load();
|
||||
for (_, v) in sts_accounts.iter() {
|
||||
if v.credentials.parent_user == access_key {
|
||||
user_exists = true;
|
||||
if v.credentials.is_temp() && !v.credentials.is_service_account() {
|
||||
let mut u = v.clone();
|
||||
u.credentials.secret_key = String::new();
|
||||
u.credentials.session_token = String::new();
|
||||
|
||||
ret.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !user_exists {
|
||||
return Err(Error::NoSuchUser(access_key.to_string()));
|
||||
}
|
||||
@@ -653,8 +667,8 @@ where
|
||||
}
|
||||
|
||||
pub async fn list_sts_accounts(&self, access_key: &str) -> Result<Vec<Credentials>> {
|
||||
let users = self.cache.users.load();
|
||||
Ok(users
|
||||
let sts_accounts = self.cache.sts_accounts.load();
|
||||
Ok(sts_accounts
|
||||
.values()
|
||||
.filter_map(|x| {
|
||||
if !access_key.is_empty()
|
||||
@@ -1421,14 +1435,13 @@ where
|
||||
}
|
||||
|
||||
pub async fn is_temp_user(&self, access_key: &str) -> Result<(bool, String)> {
|
||||
let users = self.cache.users.load();
|
||||
let u = match users.get(access_key) {
|
||||
let u = match self.get_user(access_key).await {
|
||||
Some(u) => u,
|
||||
None => return Err(Error::NoSuchUser(access_key.to_string())),
|
||||
};
|
||||
|
||||
if u.credentials.is_temp() {
|
||||
Ok((true, u.credentials.parent_user.clone()))
|
||||
Ok((true, u.credentials.parent_user))
|
||||
} else {
|
||||
Ok((false, String::new()))
|
||||
}
|
||||
|
||||
@@ -227,20 +227,13 @@ impl ObjectStore {
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
fn encrypt_data(data: &[u8]) -> Result<Vec<u8>> {
|
||||
fn prepare_data_for_storage(data: &[u8]) -> Result<Vec<u8>> {
|
||||
if keyring::encrypt_key().is_some() {
|
||||
let encrypted = Self::encrypt_data_with_master_key(data)?;
|
||||
return Ok(encrypted);
|
||||
}
|
||||
|
||||
let cred = get_global_action_cred().unwrap_or_default();
|
||||
let password = if !cred.access_key.is_empty() && !cred.secret_key.is_empty() {
|
||||
format!("{}:{}", cred.access_key, cred.secret_key).into_bytes()
|
||||
} else {
|
||||
cred.secret_key.into_bytes()
|
||||
};
|
||||
let en = rustfs_crypto::encrypt_stream_io(&password, data)?;
|
||||
Ok(en)
|
||||
Ok(data.to_vec())
|
||||
}
|
||||
|
||||
fn should_lazy_rewrite(source: DecryptSource) -> bool {
|
||||
@@ -343,8 +336,8 @@ impl ObjectStore {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn encrypt_data_for_test(data: &[u8]) -> Result<Vec<u8>> {
|
||||
Self::encrypt_data(data)
|
||||
fn prepare_data_for_storage_for_test(data: &[u8]) -> Result<Vec<u8>> {
|
||||
Self::prepare_data_for_storage(data)
|
||||
}
|
||||
|
||||
async fn load_iamconfig_bytes_with_metadata(&self, path: impl AsRef<str> + Send) -> Result<(Vec<u8>, ObjectInfo)> {
|
||||
@@ -656,7 +649,7 @@ impl Store for ObjectStore {
|
||||
#[tracing::instrument(skip(self, item, path))]
|
||||
async fn save_iam_config<Item: Serialize + Send>(&self, item: Item, path: impl AsRef<str> + Send) -> Result<()> {
|
||||
let mut data = serde_json::to_vec(&item)?;
|
||||
data = Self::encrypt_data(&data)?;
|
||||
data = Self::prepare_data_for_storage(&data)?;
|
||||
|
||||
let mut attempts = 0;
|
||||
let max_attempts = 5;
|
||||
@@ -1458,28 +1451,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_data_produces_stream_io_format() {
|
||||
let _ = test_cred();
|
||||
#[serial]
|
||||
fn test_prepare_data_defaults_to_plaintext_without_iam_master_key() {
|
||||
let plain = br#"{"Version":1,"policy":"readonly"}"#;
|
||||
let encrypted = ObjectStore::encrypt_data_for_test(plain).expect("encrypt should succeed");
|
||||
// stream_io header: salt(32) + alg_id(1) + nonce_prefix(8) = 41 bytes
|
||||
const STREAM_IO_HEADER_LEN: usize = 41;
|
||||
assert!(
|
||||
encrypted.len() >= STREAM_IO_HEADER_LEN,
|
||||
"encrypted should have at least 41-byte stream_io header"
|
||||
|
||||
with_vars(
|
||||
[
|
||||
(keyring::ENV_IAM_MASTER_KEY, None::<&str>),
|
||||
(keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None::<&str>),
|
||||
],
|
||||
|| {
|
||||
let stored = ObjectStore::prepare_data_for_storage_for_test(plain).expect("store bytes should build");
|
||||
assert_eq!(stored, plain);
|
||||
|
||||
let decrypted = ObjectStore::decrypt_data_with_source(&stored).expect("plaintext should load");
|
||||
assert_eq!(plain, decrypted.plain.as_slice());
|
||||
assert_eq!(decrypted.source, DecryptSource::Plaintext);
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
encrypted[32] == 0x00 || encrypted[32] == 0x01 || encrypted[32] == 0x02,
|
||||
"alg_id should be 0x00, 0x01, or 0x02"
|
||||
);
|
||||
// Round-trip: encrypt then decrypt
|
||||
let decrypted = ObjectStore::decrypt_data_with_source(&encrypted).expect("decrypt should succeed");
|
||||
assert_eq!(plain, decrypted.plain.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_encrypt_data_prefers_iam_master_key_roundtrip() {
|
||||
fn test_prepare_data_uses_iam_master_key_roundtrip() {
|
||||
let _ = test_cred();
|
||||
let plain = br#"{"Version":1,"policy":"master-key"}"#;
|
||||
let master_key = "iam-master-key-roundtrip";
|
||||
@@ -1490,7 +1484,7 @@ mod tests {
|
||||
(keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None),
|
||||
],
|
||||
|| {
|
||||
let encrypted = ObjectStore::encrypt_data_for_test(plain).expect("encrypt with iam master key");
|
||||
let encrypted = ObjectStore::prepare_data_for_storage_for_test(plain).expect("encrypt with iam master key");
|
||||
|
||||
let by_master =
|
||||
rustfs_crypto::decrypt_stream_io(master_key.as_bytes(), &encrypted).expect("decrypt via master key");
|
||||
|
||||
+194
-11
@@ -867,16 +867,6 @@ impl<T: Store> IamSys<T> {
|
||||
};
|
||||
}
|
||||
|
||||
let Ok((is_temp, parent_user)) = self.is_temp_user(args.account).await else {
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: false,
|
||||
mode: PreparedIamMode::Deny,
|
||||
};
|
||||
};
|
||||
if is_temp {
|
||||
return self.prepare_sts_auth(args, &parent_user).await;
|
||||
}
|
||||
|
||||
let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else {
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: false,
|
||||
@@ -887,6 +877,16 @@ impl<T: Store> IamSys<T> {
|
||||
return self.prepare_service_account_auth(args, &parent_user).await;
|
||||
}
|
||||
|
||||
let Ok((is_temp, parent_user)) = self.is_temp_user(args.account).await else {
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: false,
|
||||
mode: PreparedIamMode::Deny,
|
||||
};
|
||||
};
|
||||
if is_temp {
|
||||
return self.prepare_sts_auth(args, &parent_user).await;
|
||||
}
|
||||
|
||||
self.prepare_regular_auth(args).await
|
||||
}
|
||||
|
||||
@@ -1295,7 +1295,7 @@ mod tests {
|
||||
use crate::manager::get_default_policyes;
|
||||
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
|
||||
use rustfs_credentials::{Credentials, get_global_action_cred, init_global_action_credentials};
|
||||
use rustfs_policy::auth::UserIdentity;
|
||||
use rustfs_policy::auth::{UserIdentity, get_new_credentials_with_metadata};
|
||||
use rustfs_policy::policy::Args;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
||||
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
|
||||
@@ -1677,6 +1677,189 @@ mod tests {
|
||||
assert_eq!(claims.get("exp").and_then(|v| v.as_i64()), Some(updated_expiration.unix_timestamp()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_created_access_token_authorizes_with_parent_policy() {
|
||||
ensure_test_global_credentials();
|
||||
|
||||
let store = StsTestMockStore { empty_policies: false };
|
||||
let cache_manager = IamCache::new(store).await;
|
||||
let iam_sys = IamSys::new(cache_manager);
|
||||
|
||||
let parent_user = "sts-fallback-test-parent";
|
||||
let groups = Some(vec!["testgroup".to_string()]);
|
||||
let (cred, _) = iam_sys
|
||||
.new_service_account(
|
||||
parent_user,
|
||||
groups.clone(),
|
||||
NewServiceAccountOpts {
|
||||
access_key: "ACCESSTOKENTESTUSER".to_string(),
|
||||
secret_key: "accessTokenTestSecret".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("access token should be created");
|
||||
|
||||
let stored = iam_sys
|
||||
.get_user(&cred.access_key)
|
||||
.await
|
||||
.expect("created access token should be cached");
|
||||
assert!(stored.credentials.is_service_account());
|
||||
assert_eq!(stored.credentials.parent_user, parent_user);
|
||||
|
||||
let claims = stored
|
||||
.credentials
|
||||
.claims
|
||||
.as_ref()
|
||||
.expect("created access token should have decoded JWT claims");
|
||||
assert_eq!(claims.get("parent").and_then(Value::as_str), Some(parent_user));
|
||||
assert_eq!(
|
||||
claims.get(&iam_policy_claim_name_sa()).and_then(Value::as_str),
|
||||
Some(INHERITED_POLICY_TYPE)
|
||||
);
|
||||
|
||||
let (is_service_account, resolved_parent) = iam_sys
|
||||
.is_service_account(&cred.access_key)
|
||||
.await
|
||||
.expect("created access token should be recognized as a service account");
|
||||
assert!(is_service_account);
|
||||
assert_eq!(resolved_parent, parent_user);
|
||||
|
||||
let (redacted, policy) = iam_sys
|
||||
.get_service_account(&cred.access_key)
|
||||
.await
|
||||
.expect("created access token should be readable");
|
||||
assert_eq!(redacted.access_key, cred.access_key);
|
||||
assert_eq!(redacted.parent_user, parent_user);
|
||||
assert!(redacted.secret_key.is_empty());
|
||||
assert!(redacted.session_token.is_empty());
|
||||
assert!(policy.is_none());
|
||||
|
||||
let args = Args {
|
||||
account: &cred.access_key,
|
||||
groups: &groups,
|
||||
action: Action::S3Action(S3Action::ListBucketAction),
|
||||
bucket: "mybucket",
|
||||
conditions: &HashMap::new(),
|
||||
is_owner: false,
|
||||
object: "",
|
||||
claims,
|
||||
deny_only: false,
|
||||
};
|
||||
|
||||
let prepared = iam_sys.prepare_auth(&args).await;
|
||||
assert!(
|
||||
matches!(prepared.mode, PreparedIamMode::ServiceAccount { .. }),
|
||||
"created access token must use service-account authorization path"
|
||||
);
|
||||
assert!(
|
||||
iam_sys.eval_prepared(&prepared, &args).await,
|
||||
"created access token should be allowed through the parent's group policy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_created_sts_credentials_authorize_with_session_token_claims() {
|
||||
ensure_test_global_credentials();
|
||||
|
||||
let store = StsTestMockStore { empty_policies: false };
|
||||
let cache_manager = IamCache::new(store).await;
|
||||
let iam_sys = IamSys::new(cache_manager);
|
||||
|
||||
let parent_user = "sts-fallback-test-parent";
|
||||
let token_secret = get_global_action_cred()
|
||||
.expect("global action credentials should be initialized")
|
||||
.secret_key;
|
||||
let mut claims = HashMap::new();
|
||||
claims.insert("parent".to_string(), Value::String(parent_user.to_string()));
|
||||
claims.insert(
|
||||
"exp".to_string(),
|
||||
Value::Number(serde_json::Number::from(
|
||||
(OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp(),
|
||||
)),
|
||||
);
|
||||
|
||||
let mut cred = get_new_credentials_with_metadata(&claims, &token_secret).expect("STS credentials should be created");
|
||||
cred.parent_user = parent_user.to_string();
|
||||
|
||||
iam_sys
|
||||
.set_temp_user(&cred.access_key, &cred, None)
|
||||
.await
|
||||
.expect("STS credentials should be persisted in the temp-user cache");
|
||||
|
||||
let stored = iam_sys
|
||||
.get_user(&cred.access_key)
|
||||
.await
|
||||
.expect("created STS credentials should be cached");
|
||||
assert!(stored.credentials.is_temp());
|
||||
assert!(!stored.credentials.is_service_account());
|
||||
assert_eq!(stored.credentials.parent_user, parent_user);
|
||||
|
||||
let (is_temp, resolved_parent) = iam_sys
|
||||
.is_temp_user(&cred.access_key)
|
||||
.await
|
||||
.expect("created STS credentials should be recognized as temp");
|
||||
assert!(is_temp);
|
||||
assert_eq!(resolved_parent, parent_user);
|
||||
|
||||
let listed = iam_sys
|
||||
.list_sts_accounts(parent_user)
|
||||
.await
|
||||
.expect("created STS credentials should be listable by parent");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].access_key, cred.access_key);
|
||||
assert_eq!(listed[0].parent_user, parent_user);
|
||||
assert!(listed[0].secret_key.is_empty());
|
||||
assert!(listed[0].session_token.is_empty());
|
||||
|
||||
let temp_accounts = iam_sys
|
||||
.list_temp_accounts(parent_user)
|
||||
.await
|
||||
.expect("created STS credentials should be listable as temp accounts");
|
||||
assert_eq!(temp_accounts.len(), 1);
|
||||
assert_eq!(temp_accounts[0].credentials.access_key, cred.access_key);
|
||||
assert_eq!(temp_accounts[0].credentials.parent_user, parent_user);
|
||||
assert!(temp_accounts[0].credentials.secret_key.is_empty());
|
||||
assert!(temp_accounts[0].credentials.session_token.is_empty());
|
||||
|
||||
let (redacted, policy) = iam_sys
|
||||
.get_temporary_account(&cred.access_key)
|
||||
.await
|
||||
.expect("created STS credentials should be readable");
|
||||
assert_eq!(redacted.access_key, cred.access_key);
|
||||
assert_eq!(redacted.parent_user, parent_user);
|
||||
assert!(redacted.secret_key.is_empty());
|
||||
assert!(redacted.session_token.is_empty());
|
||||
assert!(policy.is_none());
|
||||
|
||||
let decoded_claims = get_claims_from_token_with_secret(&cred.session_token, &token_secret)
|
||||
.expect("created STS session token should decode with the active signing key");
|
||||
assert_eq!(decoded_claims.get("parent").and_then(Value::as_str), Some(parent_user));
|
||||
|
||||
let groups: Option<Vec<String>> = None;
|
||||
let args = Args {
|
||||
account: &cred.access_key,
|
||||
groups: &groups,
|
||||
action: Action::S3Action(S3Action::ListBucketAction),
|
||||
bucket: "mybucket",
|
||||
conditions: &HashMap::new(),
|
||||
is_owner: false,
|
||||
object: "",
|
||||
claims: &decoded_claims,
|
||||
deny_only: false,
|
||||
};
|
||||
|
||||
let prepared = iam_sys.prepare_auth(&args).await;
|
||||
assert!(
|
||||
matches!(prepared.mode, PreparedIamMode::Sts { .. }),
|
||||
"created STS credentials must use STS authorization path"
|
||||
);
|
||||
assert!(
|
||||
iam_sys.eval_prepared(&prepared, &args).await,
|
||||
"created STS credentials should inherit the parent user's group policy"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test: temp credentials without groups in args still receive group-attached
|
||||
/// policies via the parent user (groups fallback). Without the fallback, policy_db_get
|
||||
/// would get None for groups and the user would have no group policies, so the action
|
||||
|
||||
Reference in New Issue
Block a user