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.
This commit is contained in:
GatewayJ
2026-09-11 13:58:31 +08:00
committed by GitHub
parent 0cbc3ffe61
commit f02bc947cd
3 changed files with 156 additions and 14 deletions
+59 -12
View File
@@ -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<AdminOperation>) -> 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
}
#[async_trait::async_trait]
impl Operation for SelfAccountInfoHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
@@ -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 {
+37 -2
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
}
pub fn register_account_info_route(r: &mut S3Router<AdminOperation>) -> 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
@@ -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<String>, Option<String>) {
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");