From f02bc947cdb4c04717e4610f8d607f7ca7de4599 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Fri, 11 Sep 2026 13:58:31 +0800 Subject: [PATCH] fix(admin): expose OIDC account display fields (#7654) Expose verified OIDC username and email claims as display-only metadata on self-account responses while preserving the virtual parent as the authorization identity.\n\nKeep rustfs-madmin public response structs unchanged by adding the optional wire fields through private handler response wrappers. --- rustfs/src/admin/handlers/account.rs | 71 +++++++++++++++++---- rustfs/src/admin/handlers/account_info.rs | 39 ++++++++++- rustfs/src/admin/service/caller_identity.rs | 60 +++++++++++++++++ 3 files changed, 156 insertions(+), 14 deletions(-) diff --git a/rustfs/src/admin/handlers/account.rs b/rustfs/src/admin/handlers/account.rs index 998b1900b..464a4e8ba 100644 --- a/rustfs/src/admin/handlers/account.rs +++ b/rustfs/src/admin/handlers/account.rs @@ -38,7 +38,7 @@ use super::supervise_admin_mutation; use crate::admin::auth::validate_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{current_action_credentials, current_ready_iam_handle, object_store_from_req}; -use crate::admin::service::caller_identity::CallerIdentity; +use crate::admin::service::caller_identity::{CallerIdentity, oidc_profile_fields}; use crate::admin::storage_api::s3::{self, Body, S3ErrorCode, S3Request, S3Response, S3Result}; use crate::admin::utils::read_compatible_admin_body; use crate::auth::constant_time_eq; @@ -73,6 +73,16 @@ pub fn register_account_route(r: &mut S3Router) -> std::io::Resu /// `GET /rustfs/admin/v3/account/info` pub struct SelfAccountInfoHandler {} +#[derive(Debug, serde::Serialize)] +struct SelfAccountInfoResponse { + #[serde(flatten)] + account: SelfAccountInfo, + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, +} + #[async_trait::async_trait] impl Operation for SelfAccountInfoHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { @@ -124,17 +134,22 @@ impl Operation for SelfAccountInfoHandler { None => return Err(s3::error(S3ErrorCode::ServiceUnavailable, "the object store is not ready")), }; - let info = SelfAccountInfo { - access_key: caller.access_key.clone(), - identity_type: caller.identity_type, - session_access_key: caller.session_access_key.clone(), - is_admin: caller.is_owner, - status, - member_of, - policies, - credentials_source: caller.credentials_source, - mutable: caller.mutability(), - mfa, + let (username, email) = oidc_profile_fields(&caller.credentials); + let info = SelfAccountInfoResponse { + account: SelfAccountInfo { + access_key: caller.access_key.clone(), + identity_type: caller.identity_type, + session_access_key: caller.session_access_key.clone(), + is_admin: caller.is_owner, + status, + member_of, + policies, + credentials_source: caller.credentials_source, + mutable: caller.mutability(), + mfa, + }, + username, + email, }; admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &info) @@ -548,6 +563,38 @@ fn validate_new_secret_key(request: &ChangePasswordRequest) -> S3Result<()> { mod tests { use super::*; use crate::server::ADMIN_PREFIX; + use rustfs_madmin::account::{AccountMutability, CredentialsSource}; + + #[test] + fn self_account_info_response_adds_oidc_display_fields_without_changing_base_type() { + let mut response = SelfAccountInfoResponse { + account: SelfAccountInfo { + access_key: "virtual-parent".to_string(), + identity_type: IdentityType::Sts, + session_access_key: Some("temporary-key".to_string()), + is_admin: false, + status: "enabled".to_string(), + member_of: Vec::new(), + policies: Vec::new(), + credentials_source: CredentialsSource::Iam, + mutable: AccountMutability::default(), + mfa: AccountMfaSummary::default(), + }, + username: Some("oidc-user".to_string()), + email: Some("oidc-user@example.test".to_string()), + }; + + let value = serde_json::to_value(&response).expect("serialize account response"); + assert_eq!(value["access_key"], "virtual-parent"); + assert_eq!(value["username"], "oidc-user"); + assert_eq!(value["email"], "oidc-user@example.test"); + + response.username = None; + response.email = None; + let legacy_shape = serde_json::to_value(&response).expect("serialize account response without OIDC fields"); + assert!(!legacy_shape.as_object().unwrap().contains_key("username")); + assert!(!legacy_shape.as_object().unwrap().contains_key("email")); + } fn change_request(current: &str, new: &str) -> ChangePasswordRequest { ChangePasswordRequest { diff --git a/rustfs/src/admin/handlers/account_info.rs b/rustfs/src/admin/handlers/account_info.rs index 4a3b45952..72f16335f 100644 --- a/rustfs/src/admin/handlers/account_info.rs +++ b/rustfs/src/admin/handlers/account_info.rs @@ -15,6 +15,7 @@ use crate::admin::auth::authenticate_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{current_action_credentials, object_store_from_req}; +use crate::admin::service::caller_identity::oidc_profile_fields; use crate::admin::storage_api::bucket::versioning_sys::BucketVersioningSys; use crate::admin::storage_api::contract::admin::StorageAdminApi; use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOptions}; @@ -52,6 +53,16 @@ pub struct AccountInfo { pub struct AccountInfoHandler {} +#[derive(Debug, Serialize)] +struct AccountInfoResponse { + #[serde(flatten)] + account: rustfs_madmin::AccountInfo, + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, +} + pub fn register_account_info_route(r: &mut S3Router) -> std::io::Result<()> { r.insert( Method::GET, @@ -242,6 +253,7 @@ impl Operation for AccountInfoHandler { let policy_str = serde_json::to_string(&effective_policy) .map_err(|_e| S3Error::with_message(S3ErrorCode::InternalError, "parse policy failed"))?; + let (username, email) = oidc_profile_fields(&cred); let mut account_info = rustfs_madmin::AccountInfo { account_name, server: StorageAdminApi::backend_info(store.as_ref()).await, @@ -288,8 +300,12 @@ impl Operation for AccountInfoHandler { } } - let data = serde_json::to_vec(&account_info) - .map_err(|_e| S3Error::with_message(S3ErrorCode::InternalError, "parse accountInfo failed"))?; + let data = serde_json::to_vec(&AccountInfoResponse { + account: account_info, + username, + email, + }) + .map_err(|_e| S3Error::with_message(S3ErrorCode::InternalError, "parse accountInfo failed"))?; let mut header = HeaderMap::new(); header.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -305,6 +321,25 @@ mod tests { use rustfs_policy::policy::BucketPolicy; use s3s::dto::{Destination, ReplicationRule}; + #[test] + fn accountinfo_response_adds_optional_oidc_display_fields() { + let mut response = AccountInfoResponse { + account: rustfs_madmin::AccountInfo::default(), + username: Some("oidc-user".to_string()), + email: Some("oidc-user@example.test".to_string()), + }; + + let value = serde_json::to_value(&response).expect("serialize accountinfo response"); + assert_eq!(value["username"], "oidc-user"); + assert_eq!(value["email"], "oidc-user@example.test"); + + response.username = None; + response.email = None; + let legacy_shape = serde_json::to_value(&response).expect("serialize accountinfo response without OIDC fields"); + assert!(!legacy_shape.as_object().unwrap().contains_key("username")); + assert!(!legacy_shape.as_object().unwrap().contains_key("email")); + } + #[test] fn test_account_info_structure() { // Test AccountInfo struct creation and serialization diff --git a/rustfs/src/admin/service/caller_identity.rs b/rustfs/src/admin/service/caller_identity.rs index 28cebaa4e..73bb1aef0 100644 --- a/rustfs/src/admin/service/caller_identity.rs +++ b/rustfs/src/admin/service/caller_identity.rs @@ -33,6 +33,7 @@ use rustfs_credentials::Credentials; use rustfs_iam::federation::OIDC_VIRTUAL_PARENT_CLAIM; use rustfs_iam::sys::is_rustfs_oidc_claims; use rustfs_madmin::account::{AccountMutability, CredentialsSource, IdentityType}; +use serde_json::Value; /// Claim written by the Keystone middleware onto its synthesized credentials. const KEYSTONE_ROLES_CLAIM: &str = "keystone_roles"; @@ -54,6 +55,23 @@ pub(crate) fn session_parent_identity(credentials: &Credentials) -> Option<&str> .and_then(|value| value.as_str()) } +/// Human-readable OIDC identity metadata for self-service responses. These +/// values never replace the issuer-scoped virtual parent used for authorization. +pub(crate) fn oidc_profile_fields(credentials: &Credentials) -> (Option, Option) { + let Some(claims) = credentials.claims.as_ref().filter(|claims| is_rustfs_oidc_claims(claims)) else { + return (None, None); + }; + let string_claim = |name| { + claims + .get(name) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }; + + (string_claim("preferred_username"), string_claim("email")) +} + /// Why a credential may not change its own authentication material. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CredentialMutationDenial { @@ -398,6 +416,48 @@ mod tests { assert!(!caller.mutability().password); } + #[test] + fn oidc_profile_fields_return_normalized_display_claims() { + let mut credentials = sts_session("TEMPKEY", "oidc-parent"); + credentials.claims = Some(HashMap::from([ + ("iss".to_string(), Value::String("rustfs-oidc".to_string())), + ("oidc_provider".to_string(), Value::String("entraid".to_string())), + ("sub".to_string(), Value::String("subject-123".to_string())), + ("preferred_username".to_string(), Value::String("j.bruijns@pay.nl".to_string())), + ("email".to_string(), Value::String("fallback@pay.nl".to_string())), + ])); + + assert_eq!( + oidc_profile_fields(&credentials), + (Some("j.bruijns@pay.nl".to_string()), Some("fallback@pay.nl".to_string())) + ); + } + + #[test] + fn oidc_profile_fields_omit_missing_blank_and_non_string_values() { + let mut credentials = sts_session("TEMPKEY", "oidc-parent"); + credentials.claims = Some(HashMap::from([ + ("iss".to_string(), Value::String("rustfs-oidc".to_string())), + ("oidc_provider".to_string(), Value::String("keycloak".to_string())), + ("sub".to_string(), Value::String("subject-123".to_string())), + ("preferred_username".to_string(), Value::String(" ".to_string())), + ("email".to_string(), Value::Array(vec![Value::String("user@example.test".to_string())])), + ])); + + assert_eq!(oidc_profile_fields(&credentials), (None, None)); + } + + #[test] + fn oidc_profile_fields_ignore_non_oidc_claim_shapes() { + let mut credentials = sts_session("TEMPKEY", "ordinary-parent"); + credentials.claims = Some(HashMap::from([ + ("preferred_username".to_string(), Value::String("attacker".to_string())), + ("email".to_string(), Value::String("attacker@example.test".to_string())), + ])); + + assert_eq!(oidc_profile_fields(&credentials), (None, None)); + } + #[test] fn keystone_session_is_reported_as_federated() { let mut credentials = sts_session("TEMPKEY", "keystone-parent");