mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-31 01:09:23 +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 {
|
if !user_exists {
|
||||||
return Err(Error::NoSuchUser(access_key.to_string()));
|
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>> {
|
pub async fn list_sts_accounts(&self, access_key: &str) -> Result<Vec<Credentials>> {
|
||||||
let users = self.cache.users.load();
|
let sts_accounts = self.cache.sts_accounts.load();
|
||||||
Ok(users
|
Ok(sts_accounts
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|x| {
|
.filter_map(|x| {
|
||||||
if !access_key.is_empty()
|
if !access_key.is_empty()
|
||||||
@@ -1421,14 +1435,13 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_temp_user(&self, access_key: &str) -> Result<(bool, String)> {
|
pub async fn is_temp_user(&self, access_key: &str) -> Result<(bool, String)> {
|
||||||
let users = self.cache.users.load();
|
let u = match self.get_user(access_key).await {
|
||||||
let u = match users.get(access_key) {
|
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Err(Error::NoSuchUser(access_key.to_string())),
|
None => return Err(Error::NoSuchUser(access_key.to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
if u.credentials.is_temp() {
|
if u.credentials.is_temp() {
|
||||||
Ok((true, u.credentials.parent_user.clone()))
|
Ok((true, u.credentials.parent_user))
|
||||||
} else {
|
} else {
|
||||||
Ok((false, String::new()))
|
Ok((false, String::new()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,20 +227,13 @@ impl ObjectStore {
|
|||||||
Ok(encrypted)
|
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() {
|
if keyring::encrypt_key().is_some() {
|
||||||
let encrypted = Self::encrypt_data_with_master_key(data)?;
|
let encrypted = Self::encrypt_data_with_master_key(data)?;
|
||||||
return Ok(encrypted);
|
return Ok(encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cred = get_global_action_cred().unwrap_or_default();
|
Ok(data.to_vec())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_lazy_rewrite(source: DecryptSource) -> bool {
|
fn should_lazy_rewrite(source: DecryptSource) -> bool {
|
||||||
@@ -343,8 +336,8 @@ impl ObjectStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn encrypt_data_for_test(data: &[u8]) -> Result<Vec<u8>> {
|
fn prepare_data_for_storage_for_test(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
Self::encrypt_data(data)
|
Self::prepare_data_for_storage(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_iamconfig_bytes_with_metadata(&self, path: impl AsRef<str> + Send) -> Result<(Vec<u8>, ObjectInfo)> {
|
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))]
|
#[tracing::instrument(skip(self, item, path))]
|
||||||
async fn save_iam_config<Item: Serialize + Send>(&self, item: Item, path: impl AsRef<str> + Send) -> Result<()> {
|
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)?;
|
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 mut attempts = 0;
|
||||||
let max_attempts = 5;
|
let max_attempts = 5;
|
||||||
@@ -1458,28 +1451,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_encrypt_data_produces_stream_io_format() {
|
#[serial]
|
||||||
let _ = test_cred();
|
fn test_prepare_data_defaults_to_plaintext_without_iam_master_key() {
|
||||||
let plain = br#"{"Version":1,"policy":"readonly"}"#;
|
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
|
with_vars(
|
||||||
const STREAM_IO_HEADER_LEN: usize = 41;
|
[
|
||||||
assert!(
|
(keyring::ENV_IAM_MASTER_KEY, None::<&str>),
|
||||||
encrypted.len() >= STREAM_IO_HEADER_LEN,
|
(keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None::<&str>),
|
||||||
"encrypted should have at least 41-byte stream_io header"
|
],
|
||||||
|
|| {
|
||||||
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn test_encrypt_data_prefers_iam_master_key_roundtrip() {
|
fn test_prepare_data_uses_iam_master_key_roundtrip() {
|
||||||
let _ = test_cred();
|
let _ = test_cred();
|
||||||
let plain = br#"{"Version":1,"policy":"master-key"}"#;
|
let plain = br#"{"Version":1,"policy":"master-key"}"#;
|
||||||
let master_key = "iam-master-key-roundtrip";
|
let master_key = "iam-master-key-roundtrip";
|
||||||
@@ -1490,7 +1484,7 @@ mod tests {
|
|||||||
(keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None),
|
(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 =
|
let by_master =
|
||||||
rustfs_crypto::decrypt_stream_io(master_key.as_bytes(), &encrypted).expect("decrypt via master key");
|
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 {
|
let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else {
|
||||||
return PreparedIamAuth {
|
return PreparedIamAuth {
|
||||||
needs_existing_object_tag: false,
|
needs_existing_object_tag: false,
|
||||||
@@ -887,6 +877,16 @@ impl<T: Store> IamSys<T> {
|
|||||||
return self.prepare_service_account_auth(args, &parent_user).await;
|
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
|
self.prepare_regular_auth(args).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,7 +1295,7 @@ mod tests {
|
|||||||
use crate::manager::get_default_policyes;
|
use crate::manager::get_default_policyes;
|
||||||
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
|
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
|
||||||
use rustfs_credentials::{Credentials, get_global_action_cred, init_global_action_credentials};
|
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::Args;
|
||||||
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
||||||
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
|
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()));
|
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
|
/// 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
|
/// 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
|
/// would get None for groups and the user would have no group policies, so the action
|
||||||
|
|||||||
Reference in New Issue
Block a user