mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-01 17:58:22 +00:00
fix(admin): replicate user secret-key rotation to peer sites (#6893)
This commit is contained in:
@@ -1537,7 +1537,7 @@ where
|
|||||||
Ok(deleted_at)
|
Ok(deleted_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_user_secret_key(&self, access_key: &str, secret_key: &str) -> Result<()> {
|
pub async fn update_user_secret_key(&self, access_key: &str, secret_key: &str) -> Result<(OffsetDateTime, AccountStatus)> {
|
||||||
if access_key.is_empty() || secret_key.is_empty() {
|
if access_key.is_empty() || secret_key.is_empty() {
|
||||||
return Err(Error::InvalidArgument);
|
return Err(Error::InvalidArgument);
|
||||||
}
|
}
|
||||||
@@ -1552,7 +1552,16 @@ where
|
|||||||
let mut cred = u.credentials.clone();
|
let mut cred = u.credentials.clone();
|
||||||
cred.secret_key = secret_key.to_string();
|
cred.secret_key = secret_key.to_string();
|
||||||
|
|
||||||
|
// Status is captured from the same credential snapshot the new secret
|
||||||
|
// is persisted with, so a caller replicating the rotation broadcasts
|
||||||
|
// exactly what was written rather than re-reading racily.
|
||||||
|
let status = if cred.is_valid() {
|
||||||
|
AccountStatus::Enabled
|
||||||
|
} else {
|
||||||
|
AccountStatus::Disabled
|
||||||
|
};
|
||||||
let u = UserIdentity::from(cred);
|
let u = UserIdentity::from(cred);
|
||||||
|
let updated_at = u.update_at.unwrap_or_else(OffsetDateTime::now_utc);
|
||||||
drop(cache);
|
drop(cache);
|
||||||
drop(users);
|
drop(users);
|
||||||
|
|
||||||
@@ -1560,7 +1569,8 @@ where
|
|||||||
.save_user_identity(access_key, UserType::Reg, u.clone(), None)
|
.save_user_identity(access_key, UserType::Reg, u.clone(), None)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
self.update_user_with_claims(access_key, u)
|
self.update_user_with_claims(access_key, u)?;
|
||||||
|
Ok((updated_at, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add SSH public key for a user (for SFTP authentication)
|
/// Add SSH public key for a user (for SFTP authentication)
|
||||||
|
|||||||
@@ -960,7 +960,11 @@ impl<T: Store> IamSys<T> {
|
|||||||
Ok(updated_at)
|
Ok(updated_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_user_secret_key(&self, access_key: &str, secret_key: &str) -> Result<()> {
|
pub async fn set_user_secret_key(
|
||||||
|
&self,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> Result<(OffsetDateTime, rustfs_madmin::AccountStatus)> {
|
||||||
if !is_access_key_valid(access_key) {
|
if !is_access_key_valid(access_key) {
|
||||||
return Err(IamError::InvalidAccessKeyLength);
|
return Err(IamError::InvalidAccessKeyLength);
|
||||||
}
|
}
|
||||||
@@ -969,7 +973,9 @@ impl<T: Store> IamSys<T> {
|
|||||||
return Err(IamError::InvalidSecretKeyLength);
|
return Err(IamError::InvalidSecretKeyLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.store.update_user_secret_key(access_key, secret_key).await
|
let (updated_at, status) = self.store.update_user_secret_key(access_key, secret_key).await?;
|
||||||
|
self.notify_for_user(access_key, false).await;
|
||||||
|
Ok((updated_at, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add SSH public key for a user (for SFTP authentication)
|
/// Add SSH public key for a user (for SFTP authentication)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use super::account_audit::{
|
|||||||
};
|
};
|
||||||
use super::admin_json_response;
|
use super::admin_json_response;
|
||||||
use super::iam_error::iam_error_to_s3_error;
|
use super::iam_error::iam_error_to_s3_error;
|
||||||
|
use super::site_replication::site_replication_iam_change_hook;
|
||||||
use super::supervise_admin_mutation;
|
use super::supervise_admin_mutation;
|
||||||
use crate::admin::auth::validate_admin_request;
|
use crate::admin::auth::validate_admin_request;
|
||||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||||
@@ -48,6 +49,7 @@ use matchit::Params;
|
|||||||
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
|
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
|
||||||
use rustfs_iam::mfa::service as mfa_service;
|
use rustfs_iam::mfa::service as mfa_service;
|
||||||
use rustfs_madmin::account::{AccountMfaSummary, ChangePasswordRequest, IdentityType, SelfAccountInfo, SetUserSecretKeyRequest};
|
use rustfs_madmin::account::{AccountMfaSummary, ChangePasswordRequest, IdentityType, SelfAccountInfo, SetUserSecretKeyRequest};
|
||||||
|
use rustfs_madmin::{AccountStatus, AddOrUpdateUserReq, SITE_REPL_API_VERSION, SRIAMItem, SRIAMUser};
|
||||||
use rustfs_policy::auth::is_secret_key_valid;
|
use rustfs_policy::auth::is_secret_key_valid;
|
||||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||||
use rustfs_utils::MaskedAccessKey;
|
use rustfs_utils::MaskedAccessKey;
|
||||||
@@ -239,7 +241,7 @@ impl Operation for ChangeOwnPasswordHandler {
|
|||||||
let iam_store =
|
let iam_store =
|
||||||
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
|
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
|
||||||
|
|
||||||
iam_store
|
let (updated_at, status) = iam_store
|
||||||
.set_user_secret_key(&access_key, &new_secret_key)
|
.set_user_secret_key(&access_key, &new_secret_key)
|
||||||
.await
|
.await
|
||||||
.map_err(iam_error_to_s3_error)?;
|
.map_err(iam_error_to_s3_error)?;
|
||||||
@@ -283,6 +285,11 @@ impl Operation for ChangeOwnPasswordHandler {
|
|||||||
"admin account state"
|
"admin account state"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// After the local revocation: peer delivery has no ordering
|
||||||
|
// dependency on it, and a slow peer must not delay killing the
|
||||||
|
// old sessions here.
|
||||||
|
broadcast_secret_key_rotation("change_own_password", &access_key, &new_secret_key, status, updated_at).await;
|
||||||
|
|
||||||
Ok(revoked)
|
Ok(revoked)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -390,7 +397,19 @@ impl Operation for SetUserSecretKeyHandler {
|
|||||||
let iam_store =
|
let iam_store =
|
||||||
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
|
current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?;
|
||||||
|
|
||||||
iam_store
|
// Derived credentials live outside the `iam-user` replication
|
||||||
|
// item: rotating one here would succeed locally and silently skip
|
||||||
|
// the peer broadcast, leaving the sites permanently diverged.
|
||||||
|
if let Some(existing) = iam_store.get_user(&target).await
|
||||||
|
&& (existing.credentials.is_temp() || existing.credentials.is_service_account())
|
||||||
|
{
|
||||||
|
return Err(s3::error(
|
||||||
|
S3ErrorCode::InvalidRequest,
|
||||||
|
"the target access key is a derived credential; rotate service accounts through update-service-account",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (updated_at, status) = iam_store
|
||||||
.set_user_secret_key(&target, &request.secret_key)
|
.set_user_secret_key(&target, &request.secret_key)
|
||||||
.await
|
.await
|
||||||
.map_err(iam_error_to_s3_error)?;
|
.map_err(iam_error_to_s3_error)?;
|
||||||
@@ -430,6 +449,11 @@ impl Operation for SetUserSecretKeyHandler {
|
|||||||
"admin account state"
|
"admin account state"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// After the local revocation: peer delivery has no ordering
|
||||||
|
// dependency on it, and a slow peer must not delay killing the
|
||||||
|
// old sessions here.
|
||||||
|
broadcast_secret_key_rotation("set_user_secret_key", &target, &request.secret_key, status, updated_at).await;
|
||||||
|
|
||||||
Ok(revoked)
|
Ok(revoked)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -452,6 +476,58 @@ struct ChangePasswordResult {
|
|||||||
sessions_revoked: u32,
|
sessions_revoked: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `iam-user` item a secret rotation fans out to peer sites.
|
||||||
|
///
|
||||||
|
/// The non-empty secret routes the peer through its create-user path (not the
|
||||||
|
/// status-only path), so the persisted status must ride along or a disabled
|
||||||
|
/// account would be re-enabled on the peer.
|
||||||
|
fn secret_key_rotation_item(access_key: &str, secret_key: &str, status: AccountStatus, updated_at: OffsetDateTime) -> SRIAMItem {
|
||||||
|
SRIAMItem {
|
||||||
|
r#type: "iam-user".to_string(),
|
||||||
|
iam_user: Some(SRIAMUser {
|
||||||
|
access_key: access_key.to_string(),
|
||||||
|
is_delete_req: false,
|
||||||
|
user_req: Some(AddOrUpdateUserReq {
|
||||||
|
secret_key: secret_key.to_string(),
|
||||||
|
policy: None,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
|
}),
|
||||||
|
updated_at: Some(updated_at),
|
||||||
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fan a rotated secret out to peer sites.
|
||||||
|
///
|
||||||
|
/// The rotation is already durable locally; a broadcast failure only logs,
|
||||||
|
/// matching the other IAM site-replication hooks. `status` and `updated_at`
|
||||||
|
/// come from the persisting write itself, so the item carries exactly the
|
||||||
|
/// state that was stored.
|
||||||
|
async fn broadcast_secret_key_rotation(
|
||||||
|
action: &'static str,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
status: AccountStatus,
|
||||||
|
updated_at: OffsetDateTime,
|
||||||
|
) {
|
||||||
|
if let Err(err) = site_replication_iam_change_hook(secret_key_rotation_item(access_key, secret_key, status, updated_at)).await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_ACCOUNT,
|
||||||
|
event = EVENT_ADMIN_ACCOUNT_STATE,
|
||||||
|
action,
|
||||||
|
access_key = %MaskedAccessKey(access_key),
|
||||||
|
result = "site_replication_hook_failed",
|
||||||
|
error = ?err,
|
||||||
|
"admin account state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reject a new secret that would be useless or a no-op.
|
/// Reject a new secret that would be useless or a no-op.
|
||||||
fn validate_new_secret_key(request: &ChangePasswordRequest) -> S3Result<()> {
|
fn validate_new_secret_key(request: &ChangePasswordRequest) -> S3Result<()> {
|
||||||
if !is_secret_key_valid(&request.new_secret_key) {
|
if !is_secret_key_valid(&request.new_secret_key) {
|
||||||
@@ -499,6 +575,49 @@ mod tests {
|
|||||||
validate_new_secret_key(&change_request("old-secret-key", "new-secret-key")).expect("must accept");
|
validate_new_secret_key(&change_request("old-secret-key", "new-secret-key")).expect("must accept");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotation_item_takes_the_peer_create_path_and_preserves_status() {
|
||||||
|
let ts = OffsetDateTime::now_utc();
|
||||||
|
let item = secret_key_rotation_item("rotated-user", "new-secret-key", AccountStatus::Disabled, ts);
|
||||||
|
|
||||||
|
assert_eq!(item.r#type, "iam-user");
|
||||||
|
assert_eq!(item.updated_at, Some(ts));
|
||||||
|
assert!(item.api_version.is_some());
|
||||||
|
|
||||||
|
let user = item.iam_user.expect("iam-user payload");
|
||||||
|
assert_eq!(user.access_key, "rotated-user");
|
||||||
|
assert!(!user.is_delete_req);
|
||||||
|
|
||||||
|
let req = user.user_req.expect("user_req payload");
|
||||||
|
// A non-empty secret is what routes the peer through create-user
|
||||||
|
// instead of the status-only path.
|
||||||
|
assert_eq!(req.secret_key, "new-secret-key");
|
||||||
|
// Policy must stay unset so the peer's policy mapping is untouched.
|
||||||
|
assert!(req.policy.is_none());
|
||||||
|
// A disabled account must stay disabled on the peer.
|
||||||
|
assert_eq!(req.status, AccountStatus::Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_rotation_handlers_broadcast_after_revoking_sessions() {
|
||||||
|
let src = include_str!("account.rs");
|
||||||
|
for marker in [
|
||||||
|
"impl Operation for ChangeOwnPasswordHandler",
|
||||||
|
"impl Operation for SetUserSecretKeyHandler",
|
||||||
|
] {
|
||||||
|
let start = src.find(marker).expect("handler should exist");
|
||||||
|
let block = &src[start..];
|
||||||
|
let block = &block[..block.find("\n}\n").expect("handler block end")];
|
||||||
|
let revoke = block
|
||||||
|
.find("revoke_sts_sessions_for_parent")
|
||||||
|
.expect("handler must revoke sessions");
|
||||||
|
let broadcast = block
|
||||||
|
.find("broadcast_secret_key_rotation(")
|
||||||
|
.expect("handler must broadcast the rotation to peer sites");
|
||||||
|
assert!(revoke < broadcast, "{marker}: peer broadcast must not delay the local session revocation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn route_constants_stay_under_the_admin_prefix() {
|
fn route_constants_stay_under_the_admin_prefix() {
|
||||||
// The constants spell the full path so registration has a single source
|
// The constants spell the full path so registration has a single source
|
||||||
|
|||||||
Reference in New Issue
Block a user