diff --git a/rustfs/src/admin/access_key_identity.rs b/rustfs/src/admin/access_key_identity.rs new file mode 100644 index 000000000..affcb2eed --- /dev/null +++ b/rustfs/src/admin/access_key_identity.rs @@ -0,0 +1,938 @@ +// 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::admin::handlers::iam_error::iam_error_to_s3_error; +use rustfs_credentials::Credentials as StoredCredentials; +use rustfs_iam::error::{ + is_err_no_such_account, is_err_no_such_service_account, is_err_no_such_temp_account, is_err_no_such_user, +}; +use rustfs_iam::store::Store as IamStore; +use rustfs_madmin::{ + InfoAccessKeyResp, InfoServiceAccountResp, LDAPSpecificAccessKeyInfo, OpenIDSpecificAccessKeyInfo, ServiceAccountInfo, +}; +use rustfs_policy::policy::Policy; +use s3s::{S3Result, s3_error}; +use std::collections::HashMap; +use time::OffsetDateTime; +use tracing::debug; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AccessKeyUserType { + User, + ServiceAccount, + Sts, +} + +impl AccessKeyUserType { + fn as_str(&self) -> &'static str { + match self { + Self::User => "User", + Self::ServiceAccount => "Service Account", + Self::Sts => "STS", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AccessKeyProvider { + Builtin, + Ldap, + OpenId, +} + +impl AccessKeyProvider { + fn as_str(&self) -> &'static str { + match self { + Self::Builtin => "builtin", + Self::Ldap => "ldap", + Self::OpenId => "openid", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum StableSubjectHint { + BuiltinAccessKey(String), + LdapUser(String), + OpenId { issuer: String, subject: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AccessKeyIdentity { + user_type: AccessKeyUserType, + provider: AccessKeyProvider, + stable_subject_hint: Option, +} + +impl AccessKeyIdentity { + fn from_credentials(credentials: &StoredCredentials) -> Self { + let user_type = if credentials.is_service_account() { + AccessKeyUserType::ServiceAccount + } else if credentials.is_temp() { + AccessKeyUserType::Sts + } else { + AccessKeyUserType::User + }; + + let provider = match user_type { + AccessKeyUserType::User => AccessKeyProvider::Builtin, + AccessKeyUserType::ServiceAccount | AccessKeyUserType::Sts => { + classify_provider_from_claims(credentials.claims.as_ref()) + } + }; + + let stable_subject_hint = match provider { + AccessKeyProvider::Builtin => match user_type { + AccessKeyUserType::User => Some(StableSubjectHint::BuiltinAccessKey(credentials.access_key.clone())), + AccessKeyUserType::ServiceAccount | AccessKeyUserType::Sts => None, + }, + AccessKeyProvider::Ldap => { + claim_string(credentials.claims.as_ref(), &["ldap:user", "ldap:username"]).map(StableSubjectHint::LdapUser) + } + AccessKeyProvider::OpenId => stable_openid_subject_hint(credentials.claims.as_ref()), + }; + + Self { + user_type, + provider, + stable_subject_hint, + } + } + + fn ldap_specific_info(&self, claims: Option<&HashMap>) -> LDAPSpecificAccessKeyInfo { + let username = match &self.stable_subject_hint { + Some(StableSubjectHint::LdapUser(username)) => Some(username.clone()), + _ => claim_string(claims, &["ldap:user", "ldap:username"]), + }; + + LDAPSpecificAccessKeyInfo { username } + } + + fn openid_specific_info(&self, claims: Option<&HashMap>) -> OpenIDSpecificAccessKeyInfo { + let user_id = match &self.stable_subject_hint { + Some(StableSubjectHint::OpenId { subject, .. }) => Some(subject.clone()), + _ => claim_string(claims, &["sub"]), + }; + let display_name = claim_string(claims, &["name"]); + + OpenIDSpecificAccessKeyInfo { + config_name: None, + user_id: user_id.clone(), + user_id_claim: user_id.as_ref().map(|_| "sub".to_string()), + display_name: display_name.clone(), + display_name_claim: display_name.as_ref().map(|_| "name".to_string()), + } + } +} + +fn identity_from_provider( + user_type: AccessKeyUserType, + provider: AccessKeyProvider, + claims: Option<&HashMap>, +) -> AccessKeyIdentity { + let stable_subject_hint = match provider { + AccessKeyProvider::Builtin => None, + AccessKeyProvider::Ldap => claim_string(claims, &["ldap:user", "ldap:username"]).map(StableSubjectHint::LdapUser), + AccessKeyProvider::OpenId => stable_openid_subject_hint(claims), + }; + + AccessKeyIdentity { + user_type, + provider, + stable_subject_hint, + } +} + +pub(crate) async fn resolve_info_access_key_resp( + iam_store: &rustfs_iam::sys::IamSys, + access_key: String, + target_cred: StoredCredentials, +) -> S3Result { + let identity = AccessKeyIdentity::from_credentials(&target_cred); + let user_type = identity.user_type.as_str().to_string(); + let info = if target_cred.is_service_account() { + let (_, session_policy) = iam_store.get_service_account(&access_key).await.map_err(|e| { + debug!("get service account failed, e: {:?}", e); + if is_err_no_such_service_account(&e) { + s3_error!(InvalidRequest, "access key not exist") + } else { + s3_error!(InternalError, "get service account failed") + } + })?; + build_info_service_account_resp(iam_store, &target_cred, session_policy).await? + } else if target_cred.is_temp() { + let (_, session_policy) = iam_store.get_temporary_account(&access_key).await.map_err(|e| { + debug!("get temporary account failed, e: {:?}", e); + if is_err_no_such_temp_account(&e) { + s3_error!(InvalidRequest, "access key not exist") + } else { + s3_error!(InternalError, "get temporary account failed") + } + })?; + build_info_service_account_resp(iam_store, &target_cred, session_policy).await? + } else { + let user_info = iam_store.get_user_info(&access_key).await.map_err(|e| { + debug!("get user info failed, e: {:?}", e); + if is_err_no_such_user(&e) || is_err_no_such_account(&e) { + s3_error!(InvalidRequest, "access key not exist") + } else { + iam_error_to_s3_error(e) + } + })?; + build_info_regular_user_resp(&target_cred, &user_info) + }; + + let user_provider = identity.provider.as_str().to_string(); + Ok(InfoAccessKeyResp { + access_key, + info, + user_type, + user_provider: user_provider.clone(), + ldap_specific_info: if user_provider == "ldap" { + identity.ldap_specific_info(target_cred.claims.as_ref()) + } else { + LDAPSpecificAccessKeyInfo::default() + }, + open_id_specific_info: if user_provider == "openid" { + identity.openid_specific_info(target_cred.claims.as_ref()) + } else { + OpenIDSpecificAccessKeyInfo::default() + }, + }) +} + +pub(crate) async fn build_info_service_account_resp( + iam_store: &rustfs_iam::sys::IamSys, + account: &StoredCredentials, + session_policy: Option, +) -> S3Result { + let implied_policy = session_policy + .as_ref() + .is_none_or(|policy| policy.version.is_empty() && policy.statements.is_empty()); + + let effective_policy = if implied_policy { + let policies = iam_store + .policy_db_get(&account.parent_user, &account.groups) + .await + .map_err(|e| { + debug!("get service account policy failed, e: {:?}", e); + s3_error!(InternalError, "get service account policy failed") + })?; + + Some(iam_store.get_combined_policy(&policies).await) + } else { + session_policy + }; + + let policy = effective_policy + .map(|policy| { + serde_json::to_string_pretty(&policy).map_err(|e| { + debug!("marshal policy failed, e: {:?}", e); + s3_error!(InternalError, "marshal policy failed") + }) + }) + .transpose()?; + + Ok(InfoServiceAccountResp { + parent_user: account.parent_user.clone(), + account_status: account.status.clone(), + implied_policy, + name: account.name.clone(), + description: account.description.clone(), + expiration: account.expiration, + policy, + }) +} + +pub(crate) fn build_info_regular_user_resp( + account: &StoredCredentials, + user_info: &rustfs_madmin::UserInfo, +) -> InfoServiceAccountResp { + InfoServiceAccountResp { + parent_user: String::new(), + account_status: user_info.status.as_ref().to_string(), + implied_policy: false, + policy: user_info.policy_name.clone(), + name: account.name.clone(), + description: account.description.clone(), + expiration: None, + } +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn guess_user_provider(credentials: &StoredCredentials) -> &'static str { + AccessKeyIdentity::from_credentials(credentials).provider.as_str() +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn ldap_specific_info(claims: Option<&HashMap>) -> LDAPSpecificAccessKeyInfo { + identity_from_provider(AccessKeyUserType::ServiceAccount, AccessKeyProvider::Ldap, claims).ldap_specific_info(claims) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn openid_specific_info(claims: Option<&HashMap>) -> OpenIDSpecificAccessKeyInfo { + identity_from_provider(AccessKeyUserType::ServiceAccount, AccessKeyProvider::OpenId, claims).openid_specific_info(claims) +} + +pub(crate) fn list_entry_from_credentials(account: &StoredCredentials, expiration: Option) -> ServiceAccountInfo { + ServiceAccountInfo { + parent_user: String::new(), + account_status: String::new(), + implied_policy: false, + access_key: account.access_key.clone(), + name: account.name.clone(), + description: account.description.clone(), + expiration, + } +} + +fn classify_provider_from_claims(claims: Option<&HashMap>) -> AccessKeyProvider { + if claim_string(claims, &["ldap:user", "ldap:username"]).is_some() { + return AccessKeyProvider::Ldap; + } + + if claim_string(claims, &["sub"]).is_some() { + return AccessKeyProvider::OpenId; + } + + AccessKeyProvider::Builtin +} + +fn stable_openid_subject_hint(claims: Option<&HashMap>) -> Option { + let issuer = claim_string(claims, &["iss"])?; + let subject = claim_string(claims, &["sub"])?; + + Some(StableSubjectHint::OpenId { issuer, subject }) +} + +fn claim_string(claims: Option<&HashMap>, keys: &[&str]) -> Option { + claims.and_then(|claims| { + keys.iter() + .find_map(|key| claims.get(*key).and_then(|value| value.as_str()).map(ToOwned::to_owned)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustfs_credentials::{EMBEDDED_POLICY_TYPE, IAM_POLICY_CLAIM_NAME_SA}; + use rustfs_credentials::{get_global_action_cred, init_global_action_credentials}; + use rustfs_iam::cache::Cache; + use rustfs_iam::error::Error as IamError; + use rustfs_iam::manager::IamCache; + use rustfs_iam::store::{MappedPolicy, Store, UserType}; + use rustfs_iam::sys::{IamSys, SESSION_POLICY_NAME}; + use rustfs_policy::auth::{UserIdentity, jwt_sign}; + use rustfs_policy::policy::Policy; + use serde_json::json; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicU64}; + use tokio::sync::mpsc; + + #[derive(Clone)] + struct InfoAccessKeyTestStore; + + #[async_trait::async_trait] + impl Store for InfoAccessKeyTestStore { + fn has_watcher(&self) -> bool { + false + } + + async fn save_iam_config( + &self, + _item: Item, + _path: impl AsRef + Send, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn load_iam_config( + &self, + _path: impl AsRef + Send, + ) -> rustfs_iam::error::Result { + Err(IamError::ConfigNotFound) + } + + async fn delete_iam_config(&self, _path: impl AsRef + Send) -> rustfs_iam::error::Result<()> { + Err(IamError::InvalidArgument) + } + + async fn save_user_identity( + &self, + _name: &str, + _user_type: UserType, + _item: UserIdentity, + _ttl: Option, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn delete_user_identity(&self, _name: &str, _user_type: UserType) -> rustfs_iam::error::Result<()> { + Err(IamError::InvalidArgument) + } + + async fn load_user_identity(&self, _name: &str, _user_type: UserType) -> rustfs_iam::error::Result { + Err(IamError::InvalidArgument) + } + + async fn load_user( + &self, + _name: &str, + _user_type: UserType, + _m: &mut HashMap, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn load_users( + &self, + _user_type: UserType, + _m: &mut HashMap, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn load_secret_key(&self, _name: &str, _user_type: UserType) -> rustfs_iam::error::Result { + Err(IamError::InvalidArgument) + } + + async fn save_group_info(&self, _name: &str, _item: rustfs_iam::store::GroupInfo) -> rustfs_iam::error::Result<()> { + Err(IamError::InvalidArgument) + } + + async fn delete_group_info(&self, _name: &str) -> rustfs_iam::error::Result<()> { + Err(IamError::InvalidArgument) + } + + async fn load_group( + &self, + _name: &str, + _m: &mut HashMap, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn load_groups(&self, _m: &mut HashMap) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn save_policy_doc(&self, _name: &str, _item: rustfs_policy::policy::PolicyDoc) -> rustfs_iam::error::Result<()> { + Err(IamError::InvalidArgument) + } + + async fn delete_policy_doc(&self, _name: &str) -> rustfs_iam::error::Result<()> { + Err(IamError::InvalidArgument) + } + + async fn load_policy(&self, _name: &str) -> rustfs_iam::error::Result { + Err(IamError::InvalidArgument) + } + + async fn load_policy_doc( + &self, + _name: &str, + _m: &mut HashMap, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn load_policy_docs( + &self, + _m: &mut HashMap, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn save_mapped_policy( + &self, + _name: &str, + _user_type: UserType, + _is_group: bool, + _item: MappedPolicy, + _ttl: Option, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn delete_mapped_policy( + &self, + _name: &str, + _user_type: UserType, + _is_group: bool, + ) -> rustfs_iam::error::Result<()> { + Err(IamError::InvalidArgument) + } + + async fn load_mapped_policy( + &self, + _name: &str, + _user_type: UserType, + _is_group: bool, + _m: &mut HashMap, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn load_mapped_policies( + &self, + _user_type: UserType, + _is_group: bool, + _m: &mut HashMap, + ) -> rustfs_iam::error::Result<()> { + Ok(()) + } + + async fn load_all(&self, _cache: &Cache) -> rustfs_iam::error::Result<()> { + Ok(()) + } + } + + fn test_iam_sys_with_user( + access_key: &str, + status: rustfs_madmin::AccountStatus, + policy_name: Option<&str>, + ) -> IamSys { + ensure_test_global_credentials(); + let (sender, _receiver) = mpsc::channel::(1); + let cache = Cache::default(); + let now = OffsetDateTime::now_utc(); + cache.add_or_update_user( + access_key, + &UserIdentity::from(StoredCredentials { + access_key: access_key.to_string(), + secret_key: "secret-key".to_string(), + status: match status { + rustfs_madmin::AccountStatus::Enabled => "on".to_string(), + rustfs_madmin::AccountStatus::Disabled => "off".to_string(), + }, + ..Default::default() + }), + now, + ); + if let Some(policy_name) = policy_name { + cache.add_or_update_user_policy(access_key, &MappedPolicy::new(policy_name), now); + } + + IamSys::new(Arc::new(IamCache { + api: InfoAccessKeyTestStore, + cache, + state: Arc::new(AtomicU8::new(2)), + loading: Arc::new(AtomicBool::new(false)), + roles: HashMap::new(), + send_chan: sender, + last_timestamp: AtomicI64::new(now.unix_timestamp()), + sync_failures: AtomicU64::new(0), + sync_successes: AtomicU64::new(0), + last_sync_duration_millis: AtomicU64::new(0), + })) + } + + fn ensure_test_global_credentials() { + if get_global_action_cred().is_none() { + let _ = init_global_action_credentials(Some("TESTROOTACCESSKEY".to_string()), Some("TESTROOTSECRET123".to_string())); + } + } + + fn test_identity_with_signed_claims(mut identity: UserIdentity) -> UserIdentity { + if let Some(mut claims) = identity.credentials.claims.clone() { + if identity.credentials.is_temp() && !claims.contains_key("exp") { + claims.insert( + "exp".to_string(), + json!((OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp()), + ); + } + + identity.credentials.session_token = + jwt_sign(&claims, &identity.credentials.secret_key).expect("sign derived account claims"); + identity.credentials.claims = Some(claims); + } + + identity + } + + fn test_temp_identity_with_signed_claims(mut identity: UserIdentity) -> UserIdentity { + let mut claims = identity.credentials.claims.clone().unwrap_or_default(); + if !claims.contains_key("exp") { + claims.insert( + "exp".to_string(), + json!((OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp()), + ); + } + + identity.credentials.session_token = + jwt_sign(&claims, &identity.credentials.secret_key).expect("sign temp account claims"); + identity.credentials.claims = Some(claims); + identity + } + + fn test_iam_sys_with_service_account(identity: UserIdentity) -> (IamSys, StoredCredentials) { + ensure_test_global_credentials(); + let (sender, _receiver) = mpsc::channel::(1); + let cache = Cache::default(); + let now = OffsetDateTime::now_utc(); + let identity = test_identity_with_signed_claims(identity); + let signed_credentials = identity.credentials.clone(); + cache.add_or_update_user(&identity.credentials.access_key, &identity, now); + + let iam_sys = IamSys::new(Arc::new(IamCache { + api: InfoAccessKeyTestStore, + cache, + state: Arc::new(AtomicU8::new(2)), + loading: Arc::new(AtomicBool::new(false)), + roles: HashMap::new(), + send_chan: sender, + last_timestamp: AtomicI64::new(now.unix_timestamp()), + sync_failures: AtomicU64::new(0), + sync_successes: AtomicU64::new(0), + last_sync_duration_millis: AtomicU64::new(0), + })); + + (iam_sys, signed_credentials) + } + + fn test_iam_sys_with_temp_identity(identity: UserIdentity) -> IamSys { + ensure_test_global_credentials(); + let (sender, _receiver) = mpsc::channel::(1); + let cache = Cache::default(); + let now = OffsetDateTime::now_utc(); + let identity = test_temp_identity_with_signed_claims(identity); + cache.add_or_update_sts_account(&identity.credentials.access_key, &identity, now); + + IamSys::new(Arc::new(IamCache { + api: InfoAccessKeyTestStore, + cache, + state: Arc::new(AtomicU8::new(2)), + loading: Arc::new(AtomicBool::new(false)), + roles: HashMap::new(), + send_chan: sender, + last_timestamp: AtomicI64::new(now.unix_timestamp()), + sync_failures: AtomicU64::new(0), + sync_successes: AtomicU64::new(0), + last_sync_duration_millis: AtomicU64::new(0), + })) + } + + fn test_embedded_session_policy() -> Policy { + serde_json::from_value(json!({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::bucket/*"] + } + ] + })) + .expect("embedded session policy") + } + + fn embedded_session_policy_claims( + mut claims: HashMap, + policy: &Policy, + ) -> HashMap { + let encoded_policy = + base64_simd::URL_SAFE_NO_PAD.encode_to_string(serde_json::to_vec(policy).expect("marshal embedded session policy")); + claims.insert(IAM_POLICY_CLAIM_NAME_SA.to_string(), json!(EMBEDDED_POLICY_TYPE)); + claims.insert(SESSION_POLICY_NAME.to_string(), json!(encoded_policy)); + claims + } + + fn temp_account_claims(mut claims: HashMap) -> HashMap { + claims.insert( + "exp".to_string(), + json!((OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp()), + ); + claims + } + + #[test] + fn builtin_regular_user_identity_uses_access_key_as_stable_hint() { + let credentials = StoredCredentials { + access_key: "builtin-user".to_string(), + ..Default::default() + }; + + let identity = AccessKeyIdentity::from_credentials(&credentials); + + assert_eq!(identity.user_type, AccessKeyUserType::User); + assert_eq!(identity.provider, AccessKeyProvider::Builtin); + assert_eq!( + identity.stable_subject_hint, + Some(StableSubjectHint::BuiltinAccessKey("builtin-user".to_string())) + ); + } + + #[test] + fn builtin_regular_user_takes_precedence_over_ldap_like_claims() { + let credentials = StoredCredentials { + access_key: "svc".to_string(), + parent_user: "parent".to_string(), + claims: Some(HashMap::from([("ldap:username".to_string(), json!("alice"))])), + ..Default::default() + }; + + let identity = AccessKeyIdentity::from_credentials(&credentials); + + assert_eq!(identity.user_type, AccessKeyUserType::User); + assert_eq!(identity.provider, AccessKeyProvider::Builtin); + assert_eq!(identity.stable_subject_hint, Some(StableSubjectHint::BuiltinAccessKey("svc".to_string()))); + } + + #[test] + fn ldap_service_account_identity_uses_known_ldap_claim_as_stable_hint() { + let credentials = StoredCredentials { + access_key: "svc".to_string(), + parent_user: "parent".to_string(), + claims: Some(HashMap::from([ + ("ldap:username".to_string(), json!("alice")), + (rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA.to_string(), json!("embedded")), + ])), + ..Default::default() + }; + + let identity = AccessKeyIdentity::from_credentials(&credentials); + + assert_eq!(identity.user_type, AccessKeyUserType::ServiceAccount); + assert_eq!(identity.provider, AccessKeyProvider::Ldap); + assert_eq!(identity.stable_subject_hint, Some(StableSubjectHint::LdapUser("alice".to_string()))); + } + + #[test] + fn openid_identity_requires_issuer_for_stable_hint_but_keeps_legacy_user_id_projection() { + let claims = HashMap::from([ + ("sub".to_string(), json!("subject-123")), + ("name".to_string(), json!("RustFS User")), + ]); + let credentials = StoredCredentials { + access_key: "sts".to_string(), + session_token: "session-token".to_string(), + parent_user: "parent".to_string(), + claims: Some(claims.clone()), + ..Default::default() + }; + + let identity = AccessKeyIdentity::from_credentials(&credentials); + let openid_info = identity.openid_specific_info(Some(&claims)); + + assert_eq!(identity.user_type, AccessKeyUserType::Sts); + assert_eq!(identity.provider, AccessKeyProvider::OpenId); + assert_eq!(identity.stable_subject_hint, None); + assert_eq!(openid_info.user_id.as_deref(), Some("subject-123")); + assert_eq!(openid_info.display_name.as_deref(), Some("RustFS User")); + } + + #[test] + fn openid_identity_uses_issuer_and_subject_as_stable_hint() { + let credentials = StoredCredentials { + access_key: "sts".to_string(), + session_token: "session-token".to_string(), + parent_user: "parent".to_string(), + claims: Some(HashMap::from([ + ("iss".to_string(), json!("https://issuer.example/realms/rustfs")), + ("sub".to_string(), json!("subject-123")), + ])), + ..Default::default() + }; + + let identity = AccessKeyIdentity::from_credentials(&credentials); + + assert_eq!( + identity.stable_subject_hint, + Some(StableSubjectHint::OpenId { + issuer: "https://issuer.example/realms/rustfs".to_string(), + subject: "subject-123".to_string(), + }) + ); + } + + #[tokio::test] + async fn resolve_info_access_key_resp_preserves_regular_user_contract() { + let iam_sys = test_iam_sys_with_user("builtin-user", rustfs_madmin::AccountStatus::Enabled, Some("consoleAdmin")); + let credentials = StoredCredentials { + access_key: "builtin-user".to_string(), + name: Some("Builtin User".to_string()), + description: Some("regular user".to_string()), + ..Default::default() + }; + + let resp = resolve_info_access_key_resp(&iam_sys, "builtin-user".to_string(), credentials) + .await + .expect("resolve info access key"); + + assert_eq!(resp.user_type, "User"); + assert_eq!(resp.user_provider, "builtin"); + assert_eq!(resp.info.account_status, "enabled"); + assert_eq!(resp.info.policy.as_deref(), Some("consoleAdmin")); + assert_eq!(resp.info.name.as_deref(), Some("Builtin User")); + assert_eq!(resp.info.description.as_deref(), Some("regular user")); + assert_eq!(resp.ldap_specific_info.username, None); + assert_eq!(resp.open_id_specific_info.user_id, None); + + let body = serde_json::to_value(&resp).expect("serialize regular user response"); + assert_eq!(body.get("userType").and_then(|v| v.as_str()), Some("User")); + assert_eq!(body.get("userProvider").and_then(|v| v.as_str()), Some("builtin")); + assert!(body.get("accessKey").is_some()); + assert!(body.get("accountStatus").is_some()); + } + + #[tokio::test] + async fn resolve_info_access_key_resp_maps_missing_regular_user_to_access_key_not_exist() { + let iam_sys = test_iam_sys_with_user("existing-user", rustfs_madmin::AccountStatus::Enabled, None); + let credentials = StoredCredentials { + access_key: "missing-user".to_string(), + ..Default::default() + }; + + let err = resolve_info_access_key_resp(&iam_sys, "missing-user".to_string(), credentials) + .await + .expect_err("missing regular user should fail"); + + assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("access key not exist")); + } + + #[tokio::test] + async fn resolve_info_access_key_resp_preserves_ldap_service_account_contract() { + let policy = test_embedded_session_policy(); + let credentials = StoredCredentials { + access_key: "svc-ldap".to_string(), + secret_key: "secret-key".to_string(), + parent_user: "ldap-parent".to_string(), + status: "on".to_string(), + name: Some("LDAP Service".to_string()), + description: Some("ldap derived service account".to_string()), + claims: Some(embedded_session_policy_claims( + HashMap::from([("ldap:username".to_string(), json!("alice"))]), + &policy, + )), + ..Default::default() + }; + let (iam_sys, signed_credentials) = test_iam_sys_with_service_account(UserIdentity::from(credentials.clone())); + + assert!(signed_credentials.is_service_account()); + assert!(signed_credentials.is_temp()); + + let resp = resolve_info_access_key_resp(&iam_sys, "svc-ldap".to_string(), signed_credentials) + .await + .expect("resolve ldap service account info"); + + assert_eq!(resp.user_type, "Service Account"); + assert_eq!(resp.user_provider, "ldap"); + assert_eq!(resp.info.parent_user, "ldap-parent"); + assert_eq!(resp.info.account_status, "on"); + assert!(!resp.info.implied_policy); + assert_eq!(resp.info.name.as_deref(), Some("LDAP Service")); + assert_eq!(resp.info.description.as_deref(), Some("ldap derived service account")); + assert_eq!(resp.ldap_specific_info.username.as_deref(), Some("alice")); + assert_eq!(resp.open_id_specific_info.user_id, None); + assert!(resp.info.policy.is_some()); + + let body = serde_json::to_value(&resp).expect("serialize ldap service account response"); + assert_eq!(body.get("userType").and_then(|v| v.as_str()), Some("Service Account")); + assert_eq!(body.get("userProvider").and_then(|v| v.as_str()), Some("ldap")); + assert_eq!( + body.get("ldapSpecificInfo") + .and_then(|v| v.get("username")) + .and_then(|v| v.as_str()), + Some("alice") + ); + assert!(body.get("openIDSpecificInfo").is_none()); + assert_eq!(body.get("parentUser").and_then(|v| v.as_str()), Some("ldap-parent")); + assert_eq!(body.get("accountStatus").and_then(|v| v.as_str()), Some("on")); + assert_eq!(body.get("name").and_then(|v| v.as_str()), Some("LDAP Service")); + assert_eq!(body.get("description").and_then(|v| v.as_str()), Some("ldap derived service account")); + assert!(body.get("policy").and_then(|v| v.as_str()).is_some()); + } + + #[tokio::test] + async fn resolve_info_access_key_resp_preserves_openid_sts_contract() { + let credentials = StoredCredentials { + access_key: "sts-openid".to_string(), + secret_key: "secret-key".to_string(), + session_token: "session-token".to_string(), + parent_user: "oidc-parent".to_string(), + status: "on".to_string(), + name: Some("OIDC STS".to_string()), + description: Some("openid derived temporary account".to_string()), + claims: Some(temp_account_claims(HashMap::from([ + ("iss".to_string(), json!("https://issuer.example/realms/rustfs")), + ("sub".to_string(), json!("subject-123")), + ("name".to_string(), json!("RustFS User")), + ]))), + ..Default::default() + }; + let iam_sys = test_iam_sys_with_temp_identity(UserIdentity::from(credentials.clone())); + + let resp = resolve_info_access_key_resp(&iam_sys, "sts-openid".to_string(), credentials) + .await + .expect("resolve openid sts info"); + + assert_eq!(resp.user_type, "STS"); + assert_eq!(resp.user_provider, "openid"); + assert_eq!(resp.info.parent_user, "oidc-parent"); + assert_eq!(resp.info.account_status, "on"); + assert!(resp.info.implied_policy); + assert_eq!(resp.info.name.as_deref(), Some("OIDC STS")); + assert_eq!(resp.info.description.as_deref(), Some("openid derived temporary account")); + assert_eq!(resp.ldap_specific_info.username, None); + assert_eq!(resp.open_id_specific_info.user_id.as_deref(), Some("subject-123")); + assert_eq!(resp.open_id_specific_info.user_id_claim.as_deref(), Some("sub")); + assert_eq!(resp.open_id_specific_info.display_name.as_deref(), Some("RustFS User")); + assert_eq!(resp.open_id_specific_info.display_name_claim.as_deref(), Some("name")); + assert_eq!(resp.open_id_specific_info.config_name, None); + assert_eq!( + resp.info.policy, + Some("{\n \"ID\": \"\",\n \"Version\": \"\",\n \"Statement\": []\n}".to_string()) + ); + + let body = serde_json::to_value(&resp).expect("serialize openid sts response"); + assert_eq!(body.get("userType").and_then(|v| v.as_str()), Some("STS")); + assert_eq!(body.get("userProvider").and_then(|v| v.as_str()), Some("openid")); + assert!(body.get("ldapSpecificInfo").is_none()); + assert_eq!(body.get("impliedPolicy").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + body.get("openIDSpecificInfo") + .and_then(|v| v.get("userID")) + .and_then(|v| v.as_str()), + Some("subject-123") + ); + assert_eq!( + body.get("openIDSpecificInfo") + .and_then(|v| v.get("userIDClaim")) + .and_then(|v| v.as_str()), + Some("sub") + ); + assert_eq!( + body.get("openIDSpecificInfo") + .and_then(|v| v.get("displayName")) + .and_then(|v| v.as_str()), + Some("RustFS User") + ); + assert_eq!( + body.get("openIDSpecificInfo") + .and_then(|v| v.get("displayNameClaim")) + .and_then(|v| v.as_str()), + Some("name") + ); + assert!(body.get("openIDSpecificInfo").is_some_and(|v| v.get("configName").is_none())); + assert_eq!(body.get("parentUser").and_then(|v| v.as_str()), Some("oidc-parent")); + assert_eq!(body.get("accountStatus").and_then(|v| v.as_str()), Some("on")); + assert_eq!(body.get("name").and_then(|v| v.as_str()), Some("OIDC STS")); + assert_eq!(body.get("description").and_then(|v| v.as_str()), Some("openid derived temporary account")); + assert_eq!( + body.get("policy").and_then(|v| v.as_str()), + Some("{\n \"ID\": \"\",\n \"Version\": \"\",\n \"Statement\": []\n}") + ); + } +} diff --git a/rustfs/src/admin/handlers/iam_error.rs b/rustfs/src/admin/handlers/iam_error.rs index 7390d1693..00f05eec8 100644 --- a/rustfs/src/admin/handlers/iam_error.rs +++ b/rustfs/src/admin/handlers/iam_error.rs @@ -15,7 +15,7 @@ use rustfs_iam::error::Error as IamError; use s3s::{S3Error, S3ErrorCode}; -pub(super) fn iam_error_to_s3_error(err: IamError) -> S3Error { +pub(crate) fn iam_error_to_s3_error(err: IamError) -> S3Error { let code = match &err { IamError::NoSuchUser(_) | IamError::NoSuchAccount(_) diff --git a/rustfs/src/admin/handlers/mod.rs b/rustfs/src/admin/handlers/mod.rs index ca64cc06f..f411f8489 100644 --- a/rustfs/src/admin/handlers/mod.rs +++ b/rustfs/src/admin/handlers/mod.rs @@ -21,7 +21,7 @@ pub mod event; pub mod group; pub mod heal; pub mod health; -mod iam_error; +pub(crate) mod iam_error; pub mod is_admin; pub mod kms; pub mod kms_dynamic; diff --git a/rustfs/src/admin/handlers/service_account.rs b/rustfs/src/admin/handlers/service_account.rs index a3d5cb4a1..32bbc5701 100644 --- a/rustfs/src/admin/handlers/service_account.rs +++ b/rustfs/src/admin/handlers/service_account.rs @@ -13,6 +13,7 @@ // limitations under the License. use super::iam_error::iam_error_to_s3_error; +use crate::admin::access_key_identity; use crate::admin::handlers::site_replication::site_replication_iam_change_hook; use crate::admin::utils::{encode_compatible_admin_payload, has_space_be, is_compat_admin_request, read_compatible_admin_body}; use crate::auth::{constant_time_eq, get_condition_values, get_session_token}; @@ -31,9 +32,9 @@ use rustfs_iam::store::Store as IamStore; use rustfs_iam::sys::{NewServiceAccountOpts, UpdateServiceAccountOpts}; use rustfs_madmin::{ ACCESS_KEY_LIST_ALL, ACCESS_KEY_LIST_STS_ONLY, ACCESS_KEY_LIST_SVCACC_ONLY, ACCESS_KEY_LIST_USERS_ONLY, AddServiceAccountReq, - AddServiceAccountResp, Credentials, InfoAccessKeyResp, InfoServiceAccountResp, LDAPSpecificAccessKeyInfo, ListAccessKeysResp, - ListServiceAccountsResp, OpenIDSpecificAccessKeyInfo, SITE_REPL_API_VERSION, SRIAMItem, SRSessionPolicy, SRSvcAccChange, - SRSvcAccCreate, SRSvcAccDelete, SRSvcAccUpdate, ServiceAccountInfo, TemporaryAccountInfoResp, UpdateServiceAccountReq, + AddServiceAccountResp, Credentials, InfoServiceAccountResp, ListAccessKeysResp, ListServiceAccountsResp, + SITE_REPL_API_VERSION, SRIAMItem, SRSessionPolicy, SRSvcAccChange, SRSvcAccCreate, SRSvcAccDelete, SRSvcAccUpdate, + ServiceAccountInfo, TemporaryAccountInfoResp, UpdateServiceAccountReq, }; use rustfs_policy::policy::action::{Action, AdminAction}; use rustfs_policy::policy::{Args, Policy}; @@ -416,99 +417,32 @@ fn request_user_name(cred: &StoredCredentials) -> &str { } } +fn can_fallback_view_access_key_info(requester: &StoredCredentials, target: &StoredCredentials) -> bool { + if requester.is_service_account() { + return false; + } + + if target.is_temp() || target.is_service_account() { + return request_user_name(requester) == target.parent_user; + } + + request_user_name(requester) == target.access_key +} + +fn can_fallback_view_access_key_info_after_admin_check_failed( + requester: &StoredCredentials, + target: &StoredCredentials, + no_explicit_deny: bool, +) -> bool { + no_explicit_deny && can_fallback_view_access_key_info(requester, target) +} + async fn build_info_service_account_resp( iam_store: &rustfs_iam::sys::IamSys, account: &StoredCredentials, session_policy: Option, ) -> S3Result { - let implied_policy = session_policy - .as_ref() - .is_none_or(|policy| policy.version.is_empty() && policy.statements.is_empty()); - - let effective_policy = if implied_policy { - let policies = iam_store - .policy_db_get(&account.parent_user, &account.groups) - .await - .map_err(|e| { - debug!("get service account policy failed, e: {:?}", e); - s3_error!(InternalError, "get service account policy failed") - })?; - - Some(iam_store.get_combined_policy(&policies).await) - } else { - session_policy - }; - - let policy = effective_policy - .map(|policy| { - serde_json::to_string_pretty(&policy).map_err(|e| { - debug!("marshal policy failed, e: {:?}", e); - s3_error!(InternalError, "marshal policy failed") - }) - }) - .transpose()?; - - Ok(InfoServiceAccountResp { - parent_user: account.parent_user.clone(), - account_status: account.status.clone(), - implied_policy, - name: account.name.clone(), - description: account.description.clone(), - expiration: account.expiration, - policy, - }) -} - -fn guess_user_provider(credentials: &StoredCredentials) -> &'static str { - if !credentials.is_service_account() && !credentials.is_temp() { - return "builtin"; - } - - let Some(claims) = credentials.claims.as_ref() else { - return "builtin"; - }; - - if claims.contains_key("ldap:user") || claims.contains_key("ldap:username") { - return "ldap"; - } - - if claims.contains_key("sub") { - return "openid"; - } - - "builtin" -} - -fn ldap_specific_info(claims: Option<&HashMap>) -> LDAPSpecificAccessKeyInfo { - let username = claims - .and_then(|claims| { - claims - .get("ldap:user") - .or_else(|| claims.get("ldap:username")) - .and_then(|value| value.as_str()) - }) - .map(ToOwned::to_owned); - - LDAPSpecificAccessKeyInfo { username } -} - -fn openid_specific_info(claims: Option<&HashMap>) -> OpenIDSpecificAccessKeyInfo { - let user_id = claims - .and_then(|claims| claims.get("sub")) - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned); - let display_name = claims - .and_then(|claims| claims.get("name")) - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned); - - OpenIDSpecificAccessKeyInfo { - config_name: None, - user_id: user_id.clone(), - user_id_claim: user_id.as_ref().map(|_| "sub".to_string()), - display_name: display_name.clone(), - display_name_claim: display_name.as_ref().map(|_| "name".to_string()), - } + access_key_identity::build_info_service_account_resp(iam_store, account, session_policy).await } pub struct UpdateServiceAccount {} @@ -824,31 +758,51 @@ impl Operation for InfoAccessKey { let target_cred = iam_store.get_user(&access_key).await.map(|identity| identity.credentials); + let conditions = get_condition_values( + &req.headers, + &cred, + None, + None, + req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), + ); + let claims = cred.claims_or_empty(); + if !iam_store .is_allowed(&Args { account: &cred.access_key, groups: &cred.groups, action: Action::AdminAction(AdminAction::ListServiceAccountsAdminAction), bucket: "", - conditions: &get_condition_values( - &req.headers, - &cred, - None, - None, - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ), + conditions: &conditions, is_owner: owner, object: "", - claims: cred.claims_or_empty(), + claims, deny_only: false, }) .await { + let no_explicit_deny = iam_store + .is_allowed(&Args { + account: &cred.access_key, + groups: &cred.groups, + action: Action::AdminAction(AdminAction::ListServiceAccountsAdminAction), + bucket: "", + conditions: &conditions, + is_owner: owner, + object: "", + claims, + deny_only: true, + }) + .await; + if !no_explicit_deny { + return Err(s3_error!(AccessDenied, "access denied")); + } + let Some(target_cred) = target_cred.as_ref() else { return Err(s3_error!(AccessDenied, "access denied")); }; - if request_user_name(&cred) != target_cred.parent_user { + if !can_fallback_view_access_key_info_after_admin_check_failed(&cred, target_cred, no_explicit_deny) { return Err(s3_error!(AccessDenied, "access denied")); } } @@ -857,47 +811,7 @@ impl Operation for InfoAccessKey { return Err(s3_error!(InvalidRequest, "access key not exist")); }; - let (user_type, session_policy) = if target_cred.is_temp() { - let (_, session_policy) = iam_store.get_temporary_account(&access_key).await.map_err(|e| { - debug!("get temporary account failed, e: {:?}", e); - if is_err_no_such_temp_account(&e) { - s3_error!(InvalidRequest, "access key not exist") - } else { - s3_error!(InternalError, "get temporary account failed") - } - })?; - ("STS".to_string(), session_policy) - } else if target_cred.is_service_account() { - let (_, session_policy) = iam_store.get_service_account(&access_key).await.map_err(|e| { - debug!("get service account failed, e: {:?}", e); - if is_err_no_such_service_account(&e) { - s3_error!(InvalidRequest, "access key not exist") - } else { - s3_error!(InternalError, "get service account failed") - } - })?; - ("Service Account".to_string(), session_policy) - } else { - return Err(s3_error!(InvalidRequest, "access key not exist")); - }; - - let user_provider = guess_user_provider(&target_cred).to_string(); - let resp = InfoAccessKeyResp { - access_key, - info: build_info_service_account_resp(&iam_store, &target_cred, session_policy).await?, - user_type, - user_provider: user_provider.clone(), - ldap_specific_info: if user_provider == "ldap" { - ldap_specific_info(target_cred.claims.as_ref()) - } else { - LDAPSpecificAccessKeyInfo::default() - }, - open_id_specific_info: if user_provider == "openid" { - openid_specific_info(target_cred.claims.as_ref()) - } else { - OpenIDSpecificAccessKeyInfo::default() - }, - }; + let resp = access_key_identity::resolve_info_access_key_resp(&iam_store, access_key, target_cred).await?; let body = serde_json::to_vec(&resp).map_err(|e| s3_error!(InternalError, "marshal body failed, e: {:?}", e))?; let (body, content_type) = encode_compatible_admin_payload(req.uri.path(), &cred.secret_key, body)?; @@ -1189,14 +1103,11 @@ impl Operation for ListAccessKeysBulk { access_keys.sts_keys = sts_keys .into_iter() - .map(|sts| ServiceAccountInfo { - parent_user: String::new(), - account_status: String::new(), - implied_policy: false, - access_key: sts.access_key, - name: sts.name, - description: sts.description, - expiration: expiration_for_admin_path(req.uri.path(), sts.expiration), + .map(|sts| { + access_key_identity::list_entry_from_credentials( + &sts, + expiration_for_admin_path(req.uri.path(), sts.expiration), + ) }) .collect(); @@ -1213,14 +1124,11 @@ impl Operation for ListAccessKeysBulk { access_keys.service_accounts = service_accounts .into_iter() - .map(|svc| ServiceAccountInfo { - parent_user: String::new(), - account_status: String::new(), - implied_policy: false, - access_key: svc.access_key, - name: svc.name, - description: svc.description, - expiration: expiration_for_admin_path(req.uri.path(), svc.expiration), + .map(|svc| { + access_key_identity::list_entry_from_credentials( + &svc, + expiration_for_admin_path(req.uri.path(), svc.expiration), + ) }) .collect(); @@ -1375,7 +1283,7 @@ mod tests { ..Default::default() }; - assert_eq!(guess_user_provider(&credentials), "builtin"); + assert_eq!(access_key_identity::guess_user_provider(&credentials), "builtin"); } #[test] @@ -1391,7 +1299,7 @@ mod tests { ..Default::default() }; - assert_eq!(guess_user_provider(&credentials), "ldap"); + assert_eq!(access_key_identity::guess_user_provider(&credentials), "ldap"); } #[test] @@ -1405,7 +1313,7 @@ mod tests { ..Default::default() }; - assert_eq!(guess_user_provider(&credentials), "openid"); + assert_eq!(access_key_identity::guess_user_provider(&credentials), "openid"); } #[test] @@ -1416,8 +1324,8 @@ mod tests { ("name".to_string(), json!("RustFS User")), ]); - let ldap_info = ldap_specific_info(Some(&claims)); - let openid_info = openid_specific_info(Some(&claims)); + let ldap_info = access_key_identity::ldap_specific_info(Some(&claims)); + let openid_info = access_key_identity::openid_specific_info(Some(&claims)); assert_eq!(ldap_info.username.as_deref(), Some("uid=rustfs,ou=people,dc=example,dc=com")); assert_eq!(openid_info.user_id.as_deref(), Some("subject-123")); @@ -1623,4 +1531,122 @@ mod tests { assert_eq!(merged.get("parent"), Some(&json!("owner-user"))); assert_eq!(merged.get("custom"), Some(&json!("value"))); } + + #[test] + fn fallback_access_key_info_allows_same_regular_user() { + let requester = StoredCredentials { + access_key: "owner-user".to_string(), + ..Default::default() + }; + let target = StoredCredentials { + access_key: "owner-user".to_string(), + ..Default::default() + }; + + assert!(can_fallback_view_access_key_info(&requester, &target)); + } + + #[test] + fn fallback_access_key_info_allows_parent_for_derived_credentials() { + let requester = StoredCredentials { + access_key: "sts-user".to_string(), + session_token: "session-token".to_string(), + parent_user: "owner-user".to_string(), + ..Default::default() + }; + let target = StoredCredentials { + access_key: "owner-user".to_string(), + ..Default::default() + }; + + assert!(can_fallback_view_access_key_info(&requester, &target)); + } + + #[test] + fn fallback_access_key_info_denies_service_account_for_parent_regular_user() { + let requester = StoredCredentials { + access_key: "svc-user".to_string(), + session_token: "session-token".to_string(), + parent_user: "owner-user".to_string(), + claims: Some(HashMap::from([("sa-policy".to_string(), json!("inherited-policy"))])), + ..Default::default() + }; + let target = StoredCredentials { + access_key: "owner-user".to_string(), + ..Default::default() + }; + + assert!(!can_fallback_view_access_key_info(&requester, &target)); + } + + #[test] + fn fallback_access_key_info_denies_other_regular_user() { + let requester = StoredCredentials { + access_key: "alice".to_string(), + ..Default::default() + }; + let target = StoredCredentials { + access_key: "bob".to_string(), + ..Default::default() + }; + + assert!(!can_fallback_view_access_key_info(&requester, &target)); + } + + #[test] + fn fallback_access_key_info_requires_no_explicit_deny() { + let requester = StoredCredentials { + access_key: "owner-user".to_string(), + ..Default::default() + }; + let target = StoredCredentials { + access_key: "owner-user".to_string(), + ..Default::default() + }; + + assert!(can_fallback_view_access_key_info_after_admin_check_failed(&requester, &target, true)); + assert!(!can_fallback_view_access_key_info_after_admin_check_failed(&requester, &target, false)); + } + + #[test] + fn fallback_access_key_info_requires_no_explicit_deny_for_parent_owned_derived_credentials() { + let requester = StoredCredentials { + access_key: "sts-user".to_string(), + session_token: "session-token".to_string(), + parent_user: "owner-user".to_string(), + ..Default::default() + }; + let target = StoredCredentials { + access_key: "owner-user".to_string(), + ..Default::default() + }; + + assert!(can_fallback_view_access_key_info_after_admin_check_failed(&requester, &target, true)); + assert!(!can_fallback_view_access_key_info_after_admin_check_failed(&requester, &target, false)); + } + + #[test] + fn build_info_regular_user_resp_maps_user_metadata() { + let account = StoredCredentials { + access_key: "owner-user".to_string(), + name: Some("Owner".to_string()), + description: Some("Primary user".to_string()), + ..Default::default() + }; + let user_info = rustfs_madmin::UserInfo { + policy_name: Some("readwrite".to_string()), + status: rustfs_madmin::AccountStatus::Enabled, + ..Default::default() + }; + + let resp = access_key_identity::build_info_regular_user_resp(&account, &user_info); + + assert_eq!(resp.parent_user, ""); + assert_eq!(resp.account_status, "enabled"); + assert!(!resp.implied_policy); + assert_eq!(resp.policy.as_deref(), Some("readwrite")); + assert_eq!(resp.name.as_deref(), Some("Owner")); + assert_eq!(resp.description.as_deref(), Some("Primary user")); + assert_eq!(resp.expiration, None); + } } diff --git a/rustfs/src/admin/mod.rs b/rustfs/src/admin/mod.rs index c3fc6166d..28b5d7d99 100644 --- a/rustfs/src/admin/mod.rs +++ b/rustfs/src/admin/mod.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub(crate) mod access_key_identity; mod auth; pub mod console; pub mod handlers;