mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-11 13:29:12 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user