mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 15:16:56 +00:00
fix(iam): virtualize OIDC service account parents (#5152)
* fix(iam): preserve OIDC service account policy boundary * fix(iam): virtualize OIDC service account parents * fix(iam): reject malformed OIDC policy boundaries * test(iam): isolate federated policy regression * fix(iam): keep OIDC replication envelope off claims
This commit is contained in:
@@ -212,6 +212,7 @@ metrics-util = { version = "0.20", features = ["debugging"] }
|
||||
opentelemetry_sdk = { workspace = true, features = ["rt-tokio"] }
|
||||
rsa = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
rustfs-test-utils.workspace = true
|
||||
# diagnose_e2e fixtures (archives are generated in-test, never checked in)
|
||||
zip = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
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::handlers::site_replication::{encode_service_account_replication_policy, site_replication_iam_change_hook};
|
||||
use crate::admin::runtime_sources::current_action_credentials;
|
||||
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};
|
||||
@@ -38,7 +38,7 @@ use rustfs_madmin::{
|
||||
ServiceAccountInfo, TemporaryAccountInfoResp, UpdateServiceAccountReq,
|
||||
};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use rustfs_policy::policy::{Args, Policy};
|
||||
use rustfs_policy::policy::{Args, DEFAULT_VERSION, Policy};
|
||||
use s3s::S3ErrorCode::InvalidRequest;
|
||||
use s3s::header::CONTENT_LENGTH;
|
||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, header::CONTENT_TYPE, s3_error};
|
||||
@@ -52,16 +52,38 @@ use url::form_urlencoded;
|
||||
const LOG_COMPONENT_ADMIN: &str = "admin";
|
||||
const LOG_SUBSYSTEM_SERVICE_ACCOUNT: &str = "service_account";
|
||||
const EVENT_ADMIN_SERVICE_ACCOUNT_STATE: &str = "admin_service_account_state";
|
||||
const EMPTY_EXPLICIT_SERVICE_ACCOUNT_POLICY: &str =
|
||||
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]}]}"#;
|
||||
|
||||
fn sr_session_policy_from_value(value: Option<&serde_json::Value>) -> S3Result<SRSessionPolicy> {
|
||||
let Some(value) = value else {
|
||||
fn sr_session_policy_from_policy(policy: Option<&Policy>) -> S3Result<SRSessionPolicy> {
|
||||
let Some(policy) = policy else {
|
||||
return Ok(SRSessionPolicy::default());
|
||||
};
|
||||
|
||||
let raw = serde_json::to_string(value).map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))?;
|
||||
let raw = serde_json::to_string(policy).map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))?;
|
||||
SRSessionPolicy::from_json(&raw).map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))
|
||||
}
|
||||
|
||||
fn normalize_service_account_policy(mut policy: Policy) -> S3Result<Policy> {
|
||||
if policy.statements.is_empty() && (!policy.id.is_empty() || !policy.version.is_empty()) {
|
||||
let id = policy.id;
|
||||
policy = Policy::parse_config(EMPTY_EXPLICIT_SERVICE_ACCOUNT_POLICY.as_bytes())
|
||||
.map_err(|e| s3_error!(InternalError, "parse empty service account policy failed: {:?}", e))?;
|
||||
policy.id = id;
|
||||
} else if policy.version.is_empty() && !policy.statements.is_empty() {
|
||||
policy.version = DEFAULT_VERSION.to_string();
|
||||
}
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
fn parse_new_service_account_policy(policy: Option<&serde_json::Value>) -> S3Result<Option<Policy>> {
|
||||
let Some(policy) = policy else {
|
||||
return Ok(None);
|
||||
};
|
||||
let policy = normalize_service_account_policy(parse_service_account_policy(policy)?)?;
|
||||
Ok((!policy.id.is_empty() || !policy.version.is_empty() || !policy.statements.is_empty()).then_some(policy))
|
||||
}
|
||||
|
||||
fn compat_time_sentinel() -> OffsetDateTime {
|
||||
OffsetDateTime::UNIX_EPOCH
|
||||
}
|
||||
@@ -86,18 +108,6 @@ fn delete_service_account_success_status(path: &str) -> StatusCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_derived_service_account_claims(
|
||||
target_claims: &mut HashMap<String, serde_json::Value>,
|
||||
source_claims: &HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
for (key, value) in source_claims {
|
||||
if key == "exp" {
|
||||
continue;
|
||||
}
|
||||
target_claims.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_service_account_owner_of(caller: &StoredCredentials, target_parent_user: &str) -> bool {
|
||||
let caller_parent = if caller.parent_user.is_empty() {
|
||||
caller.access_key.as_str()
|
||||
@@ -210,6 +220,27 @@ fn parse_update_service_account_policy(new_policy: Option<serde_json::Value>) ->
|
||||
Ok(Some(parse_service_account_policy(&policy)?))
|
||||
}
|
||||
|
||||
fn service_account_update_replication_change(
|
||||
access_key: &str,
|
||||
update: &UpdateServiceAccountReq,
|
||||
session_policy: Option<&Policy>,
|
||||
) -> S3Result<SRSvcAccChange> {
|
||||
Ok(SRSvcAccChange {
|
||||
update: Some(SRSvcAccUpdate {
|
||||
access_key: access_key.to_string(),
|
||||
secret_key: update.new_secret_key.clone().unwrap_or_default(),
|
||||
status: update.new_status.clone().unwrap_or_default(),
|
||||
name: update.new_name.clone().unwrap_or_default(),
|
||||
description: update.new_description.clone().unwrap_or_default(),
|
||||
session_policy: sr_session_policy_from_policy(session_policy)?,
|
||||
expiration: update.new_expiration,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register_service_account_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||
r.insert(
|
||||
Method::POST,
|
||||
@@ -293,11 +324,12 @@ impl Operation for AddServiceAccount {
|
||||
|
||||
create_req.validate().map_err(|e| S3Error::with_message(InvalidRequest, e))?;
|
||||
|
||||
let session_policy = if let Some(policy) = &create_req.policy {
|
||||
Some(parse_service_account_policy(policy)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session_policy = parse_new_service_account_policy(create_req.policy.as_ref())?;
|
||||
let replication_policy = session_policy
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))?;
|
||||
|
||||
let Some(sys_cred) = current_action_credentials() else {
|
||||
return Err(s3_error!(InvalidRequest, "get sys cred failed"));
|
||||
@@ -358,7 +390,6 @@ impl Operation for AddServiceAccount {
|
||||
}
|
||||
|
||||
let is_svc_acc = target_user == req_user || target_user == req_parent_user;
|
||||
|
||||
// GHSA-5354: confine a non-owner caller to its own scope so it cannot mint
|
||||
// a root-parented (owner) service account. Evaluated on the original
|
||||
// `target_user` the caller submitted, before the derived-credential rewrite
|
||||
@@ -370,7 +401,7 @@ impl Operation for AddServiceAccount {
|
||||
}
|
||||
|
||||
let mut target_groups = None;
|
||||
let mut opts = NewServiceAccountOpts {
|
||||
let opts = NewServiceAccountOpts {
|
||||
access_key: create_req.access_key,
|
||||
secret_key: create_req.secret_key,
|
||||
name: create_req.name,
|
||||
@@ -389,48 +420,44 @@ impl Operation for AddServiceAccount {
|
||||
}
|
||||
|
||||
target_groups = req_groups;
|
||||
|
||||
if let Some(claims) = cred.claims {
|
||||
if opts.claims.is_none() {
|
||||
opts.claims = Some(HashMap::new());
|
||||
}
|
||||
|
||||
merge_derived_service_account_claims(opts.claims.as_mut().unwrap(), &claims);
|
||||
}
|
||||
}
|
||||
|
||||
let replication_claims = opts.claims.clone().unwrap_or_default();
|
||||
let replication_policy = create_req
|
||||
.policy
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))?;
|
||||
let replication_groups = target_groups.clone().unwrap_or_default();
|
||||
let replication_name = opts.name.clone().unwrap_or_default();
|
||||
let replication_description = opts.description.clone().unwrap_or_default();
|
||||
let replication_expiration = opts.expiration;
|
||||
|
||||
let (new_cred, _) = iam_store
|
||||
.new_service_account(&target_user, target_groups, opts)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
debug!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
||||
event = EVENT_ADMIN_SERVICE_ACCOUNT_STATE,
|
||||
target_user = %target_user,
|
||||
result = "create_failed",
|
||||
error = ?e,
|
||||
"admin service account state"
|
||||
);
|
||||
match e {
|
||||
rustfs_iam::error::Error::InvalidAccessKeyLength
|
||||
| rustfs_iam::error::Error::InvalidSecretKeyLength
|
||||
| rustfs_iam::error::Error::AccessKeyAlreadyExists => iam_error_to_s3_error(e),
|
||||
err => s3_error!(InternalError, "create service account failed, e: {:?}", err),
|
||||
}
|
||||
})?;
|
||||
let create_result = if is_svc_acc {
|
||||
iam_store
|
||||
.new_service_account_from_caller(&target_user, target_groups, opts, &cred)
|
||||
.await
|
||||
} else {
|
||||
let replication_claims = opts.claims.clone().unwrap_or_default();
|
||||
iam_store
|
||||
.new_service_account(&target_user, target_groups, opts)
|
||||
.await
|
||||
.map(|(credentials, updated_at)| (credentials, updated_at, replication_claims))
|
||||
};
|
||||
let (new_cred, updated_at, replication_claims) = create_result.map_err(|e| {
|
||||
debug!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
||||
event = EVENT_ADMIN_SERVICE_ACCOUNT_STATE,
|
||||
target_user = %target_user,
|
||||
result = "create_failed",
|
||||
error = ?e,
|
||||
"admin service account state"
|
||||
);
|
||||
match e {
|
||||
rustfs_iam::error::Error::InvalidAccessKeyLength
|
||||
| rustfs_iam::error::Error::InvalidSecretKeyLength
|
||||
| rustfs_iam::error::Error::AccessKeyAlreadyExists => iam_error_to_s3_error(e),
|
||||
rustfs_iam::error::Error::IAMActionNotAllowed => s3_error!(AccessDenied, "access denied"),
|
||||
err => s3_error!(InternalError, "create service account failed, e: {:?}", err),
|
||||
}
|
||||
})?;
|
||||
let (replication_session_policy, oidc_service_account_envelope) =
|
||||
encode_service_account_replication_policy(&replication_claims, replication_policy.as_deref())?;
|
||||
|
||||
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
||||
r#type: "service-account".to_string(),
|
||||
@@ -441,22 +468,18 @@ impl Operation for AddServiceAccount {
|
||||
secret_key: new_cred.secret_key.clone(),
|
||||
groups: replication_groups,
|
||||
claims: replication_claims,
|
||||
session_policy: replication_policy
|
||||
.as_deref()
|
||||
.map(SRSessionPolicy::from_json)
|
||||
.transpose()
|
||||
.map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))?
|
||||
.unwrap_or_default(),
|
||||
session_policy: replication_session_policy,
|
||||
status: String::new(),
|
||||
name: replication_name,
|
||||
description: replication_description,
|
||||
expiration: replication_expiration,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}),
|
||||
oidc_service_account_envelope,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
updated_at: Some(OffsetDateTime::now_utc()),
|
||||
updated_at: Some(updated_at),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -620,6 +643,7 @@ impl Operation for UpdateServiceAccount {
|
||||
let new_policy = update_req.new_policy.clone();
|
||||
|
||||
let sp = parse_update_service_account_policy(new_policy.clone())?;
|
||||
let svc_acc_change = service_account_update_replication_change(&access_key, &update_req, sp.as_ref())?;
|
||||
|
||||
let opts = UpdateServiceAccountOpts {
|
||||
secret_key: new_secret_key.clone(),
|
||||
@@ -638,20 +662,7 @@ impl Operation for UpdateServiceAccount {
|
||||
|
||||
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
||||
r#type: "service-account".to_string(),
|
||||
svc_acc_change: Some(SRSvcAccChange {
|
||||
update: Some(SRSvcAccUpdate {
|
||||
access_key: access_key.clone(),
|
||||
secret_key: new_secret_key.unwrap_or_default(),
|
||||
status: new_status.unwrap_or_default(),
|
||||
name: new_name.unwrap_or_default(),
|
||||
description: new_description.unwrap_or_default(),
|
||||
session_policy: sr_session_policy_from_value(new_policy.as_ref())?,
|
||||
expiration: new_expiration,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
svc_acc_change: Some(svc_acc_change),
|
||||
updated_at: Some(updated_at),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
@@ -1352,33 +1363,37 @@ impl Operation for DeleteServiceAccount {
|
||||
}
|
||||
}
|
||||
|
||||
iam_store.delete_service_account(&query.access_key, true).await.map_err(|e| {
|
||||
debug!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
||||
event = "service_account_delete_failed",
|
||||
access_key = %query.access_key,
|
||||
error = ?e,
|
||||
"Failed to delete service account"
|
||||
);
|
||||
s3_error!(InternalError, "delete service account failed")
|
||||
})?;
|
||||
let deleted_at = iam_store
|
||||
.delete_service_account_with_revision(&query.access_key, true)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
debug!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
||||
event = "service_account_delete_failed",
|
||||
access_key = %query.access_key,
|
||||
error = ?e,
|
||||
"Failed to delete service account"
|
||||
);
|
||||
s3_error!(InternalError, "delete service account failed")
|
||||
})?;
|
||||
|
||||
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
||||
r#type: "service-account".to_string(),
|
||||
svc_acc_change: Some(SRSvcAccChange {
|
||||
delete: Some(SRSvcAccDelete {
|
||||
access_key: query.access_key.clone(),
|
||||
if let Some(deleted_at) = deleted_at
|
||||
&& let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
||||
r#type: "service-account".to_string(),
|
||||
svc_acc_change: Some(SRSvcAccChange {
|
||||
delete: Some(SRSvcAccDelete {
|
||||
access_key: query.access_key.clone(),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
updated_at: Some(deleted_at),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
updated_at: Some(OffsetDateTime::now_utc()),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
@@ -1713,6 +1728,81 @@ mod tests {
|
||||
assert!(policy.statements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_explicit_create_policy_is_normalized_before_persistence_and_replication() {
|
||||
let id_only = parse_new_service_account_policy(Some(&json!({"ID": "deny-boundary", "Version": "", "Statement": []})))
|
||||
.expect("ID-only policy should normalize")
|
||||
.expect("normalized explicit policy");
|
||||
assert_eq!(id_only.id.as_str(), "deny-boundary");
|
||||
assert_eq!(id_only.version, DEFAULT_VERSION);
|
||||
assert!(!id_only.statements.is_empty());
|
||||
|
||||
let missing_version = parse_new_service_account_policy(Some(&json!({
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::bucket/*"]
|
||||
}]
|
||||
})))
|
||||
.expect("policy version should normalize")
|
||||
.expect("normalized explicit policy");
|
||||
assert_eq!(missing_version.version, DEFAULT_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_create_policy_is_canonicalized_as_inherited() {
|
||||
let policy = parse_new_service_account_policy(Some(&json!({}))).expect("parse empty create policy");
|
||||
|
||||
assert!(policy.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_explicit_update_policy_preserves_existing_semantics() {
|
||||
let id_only = parse_update_service_account_policy(Some(json!({"ID": "clear-boundary", "Version": "", "Statement": []})))
|
||||
.expect("parse ID-only update")
|
||||
.expect("explicit update policy");
|
||||
assert_eq!(id_only.id.as_str(), "clear-boundary");
|
||||
assert!(id_only.version.is_empty());
|
||||
assert!(id_only.statements.is_empty());
|
||||
|
||||
let missing_version = parse_update_service_account_policy(Some(json!({
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::bucket/*"]
|
||||
}]
|
||||
})))
|
||||
.expect("parse versionless update")
|
||||
.expect("explicit update policy");
|
||||
assert!(missing_version.version.is_empty());
|
||||
assert!(!missing_version.statements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_update_stays_partial() {
|
||||
let update = UpdateServiceAccountReq {
|
||||
new_policy: Some(json!({})),
|
||||
new_secret_key: None,
|
||||
new_status: None,
|
||||
new_name: None,
|
||||
new_description: None,
|
||||
new_expiration: None,
|
||||
};
|
||||
|
||||
let session_policy = parse_update_service_account_policy(update.new_policy.clone()).expect("parse update policy");
|
||||
let change = service_account_update_replication_change("OIDCSERVICEACCOUNT01", &update, session_policy.as_ref())
|
||||
.expect("build replication update");
|
||||
|
||||
assert!(change.create.is_none());
|
||||
assert!(change.delete.is_none());
|
||||
let update = change.update.expect("partial update");
|
||||
let cleared: Policy =
|
||||
serde_json::from_str(update.session_policy.as_str().expect("explicit policy clear")).expect("parse policy clear");
|
||||
assert!(cleared.id.is_empty());
|
||||
assert!(cleared.version.is_empty());
|
||||
assert!(cleared.statements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_service_account_policy_reports_missing_resource() {
|
||||
let err = parse_service_account_policy(&json!({
|
||||
@@ -1774,22 +1864,6 @@ mod tests {
|
||||
assert!(!is_service_account_owner_of(&foreign_user, "owner-user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_derived_service_account_claims_skips_only_expiration() {
|
||||
let mut merged = HashMap::new();
|
||||
let source = HashMap::from([
|
||||
("exp".to_string(), json!(123456)),
|
||||
("parent".to_string(), json!("owner-user")),
|
||||
("custom".to_string(), json!("value")),
|
||||
]);
|
||||
|
||||
merge_derived_service_account_claims(&mut merged, &source);
|
||||
|
||||
assert!(!merged.contains_key("exp"));
|
||||
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 {
|
||||
|
||||
@@ -57,6 +57,7 @@ use rustfs_config::{
|
||||
MAX_ADMIN_REQUEST_BODY_SIZE,
|
||||
};
|
||||
use rustfs_iam::error::is_err_no_such_service_account;
|
||||
use rustfs_iam::federation::OIDC_VIRTUAL_PARENT_CLAIM;
|
||||
use rustfs_iam::store::{MappedPolicy, UserType};
|
||||
use rustfs_iam::sys::{
|
||||
NewServiceAccountOpts, SITE_REPLICATOR_SERVICE_ACCOUNT, UpdateServiceAccountOpts, get_claims_from_token_with_secret,
|
||||
@@ -67,8 +68,8 @@ use rustfs_madmin::{
|
||||
ReplicateEditStatus, ReplicateRemoveStatus, ResyncBucketStatus, SITE_REPL_API_VERSION, SRBucketInfo, SRBucketMeta,
|
||||
SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, SRIAMPolicy, SRILMExpiryStatsSummary, SRInfo, SRMetric,
|
||||
SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation, SRPolicyMapping, SRPolicyStatsSummary, SRRemoveReq,
|
||||
SRResyncOpStatus, SRRetryStats, SRSiteSummary, SRStateEditReq, SRStateInfo, SRStatusInfo, SRUserStatsSummary,
|
||||
SiteReplicationInfo, SyncStatus, WorkerStat,
|
||||
SRResyncOpStatus, SRRetryStats, SRSessionPolicy, SRSiteSummary, SRStateEditReq, SRStateInfo, SRStatusInfo, SRSvcAccCreate,
|
||||
SRUserStatsSummary, SiteReplicationInfo, SyncStatus, WorkerStat,
|
||||
};
|
||||
use rustfs_policy::policy::{
|
||||
Policy,
|
||||
@@ -106,7 +107,7 @@ use uuid::Uuid;
|
||||
const LOG_COMPONENT_ADMIN: &str = "admin";
|
||||
const LOG_SUBSYSTEM_SITE_REPLICATION: &str = "site_replication";
|
||||
const EVENT_ADMIN_SITE_REPLICATION_STATE: &str = "admin_site_replication_state";
|
||||
|
||||
const SERVICE_ACCOUNT_ENVELOPE_VERSION: u64 = 2;
|
||||
const SITE_REPLICATION_STATE_PATH: &str = "config/site-replication/state.json";
|
||||
const SITE_REPL_ADD_SUCCESS: &str = "Requested sites were configured for replication successfully.";
|
||||
const SITE_REPL_EDIT_SUCCESS: &str = "Requested site was updated successfully.";
|
||||
@@ -6273,6 +6274,103 @@ fn group_info_requires_upsert(update: &rustfs_madmin::GroupAddRemove) -> bool {
|
||||
!update.is_remove
|
||||
}
|
||||
|
||||
pub(crate) fn encode_service_account_replication_policy(
|
||||
claims: &HashMap<String, Value>,
|
||||
session_policy: Option<&str>,
|
||||
) -> S3Result<(SRSessionPolicy, Option<rustfs_madmin::SRSvcAccReplicationEnvelope>)> {
|
||||
if !claims.contains_key(OIDC_VIRTUAL_PARENT_CLAIM) {
|
||||
return session_policy
|
||||
.map(SRSessionPolicy::from_json)
|
||||
.transpose()
|
||||
.map(|policy| policy.unwrap_or_default())
|
||||
.map(|policy| (policy, None))
|
||||
.map_err(|err| s3_error!(InvalidArgument, "marshal policy failed: {:?}", err));
|
||||
}
|
||||
|
||||
let policy = match session_policy {
|
||||
Some(policy) => serde_json::from_str::<Policy>(policy)
|
||||
.map_err(|err| s3_error!(InvalidArgument, "invalid service account replication policy: {:?}", err))?,
|
||||
None => Policy::default(),
|
||||
};
|
||||
if policy.statements.is_empty() && (!policy.id.is_empty() || !policy.version.is_empty())
|
||||
|| policy.version.is_empty() && !policy.statements.is_empty()
|
||||
{
|
||||
return Err(s3_error!(InvalidArgument, "service account replication policy is not normalized"));
|
||||
}
|
||||
let policy = serde_json::to_string(&policy)
|
||||
.map_err(|err| s3_error!(InternalError, "marshal service account replication policy failed: {:?}", err))?;
|
||||
let policy = SRSessionPolicy::from_json(&policy)
|
||||
.map_err(|err| s3_error!(InternalError, "marshal service account replication policy failed: {:?}", err))?;
|
||||
Ok((
|
||||
policy,
|
||||
Some(rustfs_madmin::SRSvcAccReplicationEnvelope {
|
||||
version: SERVICE_ACCOUNT_ENVELOPE_VERSION,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ReplicatedServiceAccountPolicy {
|
||||
policy: Option<Policy>,
|
||||
is_envelope: bool,
|
||||
}
|
||||
|
||||
impl ReplicatedServiceAccountPolicy {
|
||||
fn for_existing_account(self) -> Option<Policy> {
|
||||
if self.is_envelope {
|
||||
Some(self.policy.unwrap_or_default())
|
||||
} else {
|
||||
self.policy
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_for_existing_account(&self, value: String) -> Option<String> {
|
||||
(self.is_envelope || !value.is_empty()).then_some(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_service_account_replication_policy(
|
||||
create: &SRSvcAccCreate,
|
||||
envelope: Option<&rustfs_madmin::SRSvcAccReplicationEnvelope>,
|
||||
incoming_updated_at: Option<OffsetDateTime>,
|
||||
local_updated_at: Option<OffsetDateTime>,
|
||||
) -> S3Result<Option<ReplicatedServiceAccountPolicy>> {
|
||||
if local_updated_at.is_some_and(|local_updated_at| is_stale_update(local_updated_at, incoming_updated_at)) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(envelope) = envelope else {
|
||||
return Ok(Some(ReplicatedServiceAccountPolicy {
|
||||
policy: create.session_policy.as_str().and_then(|raw| serde_json::from_str(raw).ok()),
|
||||
is_envelope: false,
|
||||
}));
|
||||
};
|
||||
if envelope.version != SERVICE_ACCOUNT_ENVELOPE_VERSION || !create.claims.contains_key(OIDC_VIRTUAL_PARENT_CLAIM) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid service account replication envelope"));
|
||||
}
|
||||
|
||||
if incoming_updated_at.is_none() {
|
||||
return Err(s3_error!(InvalidRequest, "service account replication envelope has no revision"));
|
||||
}
|
||||
let policy: Policy = serde_json::from_str(
|
||||
create
|
||||
.session_policy
|
||||
.as_str()
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "service account replication envelope has no session policy"))?,
|
||||
)
|
||||
.map_err(|err| s3_error!(InvalidRequest, "invalid replicated service account session policy: {}", err))?;
|
||||
if policy.statements.is_empty() && (!policy.id.is_empty() || !policy.version.is_empty())
|
||||
|| policy.version.is_empty() && !policy.statements.is_empty()
|
||||
{
|
||||
return Err(s3_error!(InvalidRequest, "replicated service account policy is not normalized"));
|
||||
}
|
||||
let policy = (!policy.id.is_empty() || !policy.version.is_empty() || !policy.statements.is_empty()).then_some(policy);
|
||||
Ok(Some(ReplicatedServiceAccountPolicy {
|
||||
policy,
|
||||
is_envelope: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
||||
let Some(iam_sys) = current_iam_handle() else {
|
||||
return Err(s3_error!(InvalidRequest, "iam not init"));
|
||||
@@ -6339,6 +6437,8 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
||||
.map(OffsetDateTime::from_unix_timestamp)
|
||||
.transpose()
|
||||
.map_err(|e| s3_error!(InvalidRequest, "invalid STS expiry: {e}"))?;
|
||||
let groups = string_list_claim(&claims, "groups");
|
||||
let compatibility_policy = sts_replication_compatibility_policy(&claims, &sts_credential.parent_policy_mapping);
|
||||
let cred = rustfs_credentials::Credentials {
|
||||
access_key: sts_credential.access_key.clone(),
|
||||
secret_key: sts_credential.secret_key.clone(),
|
||||
@@ -6346,15 +6446,12 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
||||
expiration,
|
||||
status: "on".to_string(),
|
||||
parent_user: sts_credential.parent_user.clone(),
|
||||
groups,
|
||||
claims: Some(claims),
|
||||
..Default::default()
|
||||
};
|
||||
iam_sys
|
||||
.set_temp_user(
|
||||
&sts_credential.access_key,
|
||||
&cred,
|
||||
(!sts_credential.parent_policy_mapping.is_empty()).then_some(sts_credential.parent_policy_mapping.as_str()),
|
||||
)
|
||||
.set_temp_user(&sts_credential.access_key, &cred, compatibility_policy)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(())
|
||||
@@ -6393,16 +6490,31 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
||||
let Some(change) = item.svc_acc_change else {
|
||||
return Err(s3_error!(InvalidRequest, "serviceAccountChange is required"));
|
||||
};
|
||||
let envelope = change.oidc_service_account_envelope;
|
||||
if let Some(create) = change.create {
|
||||
if let Some(local) = iam_sys.get_user(&create.access_key).await
|
||||
&& is_stale_update(local.update_at.unwrap_or(OffsetDateTime::UNIX_EPOCH), incoming_updated_at)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let session_policy = if create.access_key == SITE_REPLICATOR_SERVICE_ACCOUNT {
|
||||
Some(site_replicator_service_account_policy()?)
|
||||
let local_updated_at = iam_sys
|
||||
.get_user(&create.access_key)
|
||||
.await
|
||||
.map(|local| local.update_at.unwrap_or(OffsetDateTime::UNIX_EPOCH));
|
||||
let replicated_policy = if create.access_key == SITE_REPLICATOR_SERVICE_ACCOUNT {
|
||||
if local_updated_at.is_some_and(|local_updated_at| is_stale_update(local_updated_at, incoming_updated_at)) {
|
||||
return Ok(());
|
||||
}
|
||||
ReplicatedServiceAccountPolicy {
|
||||
policy: Some(site_replicator_service_account_policy()?),
|
||||
is_envelope: false,
|
||||
}
|
||||
} else {
|
||||
create.session_policy.as_str().and_then(|raw| serde_json::from_str(raw).ok())
|
||||
let Some(replicated_policy) = decode_service_account_replication_policy(
|
||||
&create,
|
||||
envelope.as_ref(),
|
||||
incoming_updated_at,
|
||||
local_updated_at,
|
||||
)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
replicated_policy
|
||||
};
|
||||
match iam_sys.get_service_account(&create.access_key).await {
|
||||
Ok((existing, _)) => {
|
||||
@@ -6417,10 +6529,10 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
||||
.update_service_account(
|
||||
&create.access_key,
|
||||
UpdateServiceAccountOpts {
|
||||
session_policy,
|
||||
name: replicated_policy.metadata_for_existing_account(create.name),
|
||||
description: replicated_policy.metadata_for_existing_account(create.description),
|
||||
session_policy: replicated_policy.for_existing_account(),
|
||||
secret_key: Some(create.secret_key),
|
||||
name: (!create.name.is_empty()).then_some(create.name),
|
||||
description: (!create.description.is_empty()).then_some(create.description),
|
||||
expiration: create.expiration,
|
||||
status: (!create.status.is_empty()).then_some(create.status),
|
||||
allow_site_replicator_account: create.access_key == SITE_REPLICATOR_SERVICE_ACCOUNT,
|
||||
@@ -6435,7 +6547,7 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
||||
&create.parent,
|
||||
Some(create.groups),
|
||||
NewServiceAccountOpts {
|
||||
session_policy,
|
||||
session_policy: replicated_policy.policy,
|
||||
access_key: create.access_key,
|
||||
secret_key: create.secret_key,
|
||||
name: (!create.name.is_empty()).then_some(create.name),
|
||||
@@ -6514,6 +6626,21 @@ fn claims_unix_timestamp(value: &Value) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn string_list_claim(claims: &HashMap<String, Value>, name: &str) -> Option<Vec<String>> {
|
||||
let values = claims.get(name)?.as_array()?;
|
||||
let values: Vec<String> = values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
fn sts_replication_compatibility_policy<'a>(claims: &HashMap<String, Value>, parent_policy_mapping: &'a str) -> Option<&'a str> {
|
||||
(!claims.contains_key(OIDC_VIRTUAL_PARENT_CLAIM) && !parent_policy_mapping.is_empty()).then_some(parent_policy_mapping)
|
||||
}
|
||||
|
||||
pub struct SiteReplicationAddHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -7923,6 +8050,278 @@ mod tests {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn sts_replication_restores_groups_from_signed_claims() {
|
||||
let claims = HashMap::from([("groups".to_string(), serde_json::json!(["devs", "auditors"]))]);
|
||||
|
||||
assert_eq!(
|
||||
string_list_claim(&claims, "groups"),
|
||||
Some(vec!["devs".to_string(), "auditors".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_sts_replication_uses_signed_policy_instead_of_virtual_parent_mapping() {
|
||||
let verified_claims =
|
||||
HashMap::from([(OIDC_VIRTUAL_PARENT_CLAIM.to_string(), Value::String("openid=parent".to_string()))]);
|
||||
let legacy_claims = HashMap::new();
|
||||
|
||||
assert!(sts_replication_compatibility_policy(&verified_claims, "readonly").is_none());
|
||||
assert_eq!(sts_replication_compatibility_policy(&legacy_claims, "readonly"), Some("readonly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_service_account_envelope_round_trips_actual_policy() {
|
||||
let actual_policy = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}"#;
|
||||
let updated_at = OffsetDateTime::UNIX_EPOCH;
|
||||
let claims =
|
||||
HashMap::from([(OIDC_VIRTUAL_PARENT_CLAIM.to_string(), Value::String("openid=verified-parent".to_string()))]);
|
||||
let (wire_policy, envelope) =
|
||||
encode_service_account_replication_policy(&claims, Some(actual_policy)).expect("encode envelope");
|
||||
let create = SRSvcAccCreate {
|
||||
parent: "openid=verified-parent".to_string(),
|
||||
access_key: "OIDCREPLICATEDSERVICE".to_string(),
|
||||
secret_key: "oidcReplicatedSecret123".to_string(),
|
||||
groups: Vec::new(),
|
||||
claims,
|
||||
session_policy: wire_policy,
|
||||
status: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
expiration: None,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
};
|
||||
let old_receiver_policy: Policy = serde_json::from_str(
|
||||
create
|
||||
.session_policy
|
||||
.as_str()
|
||||
.expect("old receiver gets a standard session policy"),
|
||||
)
|
||||
.expect("parse old receiver policy");
|
||||
assert_eq!(
|
||||
serde_json::to_value(old_receiver_policy).expect("serialize old receiver policy"),
|
||||
serde_json::from_str::<Value>(actual_policy).expect("parse expected policy")
|
||||
);
|
||||
assert_eq!(envelope.as_ref().map(|envelope| envelope.version), Some(SERVICE_ACCOUNT_ENVELOPE_VERSION));
|
||||
assert_eq!(create.claims.len(), 1);
|
||||
|
||||
let decoded = decode_service_account_replication_policy(&create, envelope.as_ref(), Some(updated_at), None)
|
||||
.expect("decode envelope")
|
||||
.expect("current envelope");
|
||||
assert!(decoded.is_envelope);
|
||||
let restored = decoded.policy.expect("actual policy");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored).expect("serialize restored policy"),
|
||||
serde_json::from_str::<Value>(actual_policy).expect("parse expected policy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_service_account_envelope_clears_policy_on_existing_account() {
|
||||
let updated_at = OffsetDateTime::UNIX_EPOCH;
|
||||
let claims =
|
||||
HashMap::from([(OIDC_VIRTUAL_PARENT_CLAIM.to_string(), Value::String("openid=verified-parent".to_string()))]);
|
||||
let (wire_policy, envelope) =
|
||||
encode_service_account_replication_policy(&claims, None).expect("encode inherited envelope");
|
||||
let create = SRSvcAccCreate {
|
||||
parent: "openid=verified-parent".to_string(),
|
||||
access_key: "OIDCREPLICATEDSERVICE".to_string(),
|
||||
secret_key: "oidcReplicatedSecret123".to_string(),
|
||||
groups: Vec::new(),
|
||||
claims,
|
||||
session_policy: wire_policy,
|
||||
status: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
expiration: None,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
};
|
||||
let old_receiver_policy: Policy = serde_json::from_str(
|
||||
create
|
||||
.session_policy
|
||||
.as_str()
|
||||
.expect("old receiver gets an explicit empty policy"),
|
||||
)
|
||||
.expect("parse old receiver policy");
|
||||
assert!(old_receiver_policy.version.is_empty());
|
||||
assert!(old_receiver_policy.statements.is_empty());
|
||||
|
||||
let decoded = decode_service_account_replication_policy(&create, envelope.as_ref(), Some(updated_at), None)
|
||||
.expect("decode inherited envelope")
|
||||
.expect("current envelope");
|
||||
|
||||
assert!(decoded.is_envelope);
|
||||
assert!(decoded.policy.is_none());
|
||||
assert_eq!(decoded.metadata_for_existing_account(String::new()), Some(String::new()));
|
||||
let update_policy = decoded
|
||||
.for_existing_account()
|
||||
.expect("existing account needs an explicit clear");
|
||||
assert!(update_policy.version.is_empty());
|
||||
assert!(update_policy.statements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_service_account_envelope_replays_normalized_empty_policy() {
|
||||
let actual_policy = r#"{"ID":"deny-boundary","Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]}]}"#;
|
||||
let claims =
|
||||
HashMap::from([(OIDC_VIRTUAL_PARENT_CLAIM.to_string(), Value::String("openid=verified-parent".to_string()))]);
|
||||
let (wire_policy, envelope) =
|
||||
encode_service_account_replication_policy(&claims, Some(actual_policy)).expect("encode envelope");
|
||||
let create = SRSvcAccCreate {
|
||||
parent: "openid=verified-parent".to_string(),
|
||||
access_key: "OIDCREPLICATEDSERVICE".to_string(),
|
||||
secret_key: "oidcReplicatedSecret123".to_string(),
|
||||
groups: Vec::new(),
|
||||
claims,
|
||||
session_policy: wire_policy,
|
||||
status: "on".to_string(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
expiration: None,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
};
|
||||
|
||||
let decoded =
|
||||
decode_service_account_replication_policy(&create, envelope.as_ref(), Some(OffsetDateTime::UNIX_EPOCH), None)
|
||||
.expect("decode normalized empty policy")
|
||||
.expect("current envelope");
|
||||
let restored = decoded.policy.as_ref().expect("normalized policy must remain explicit");
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored).expect("serialize restored policy"),
|
||||
serde_json::from_str::<Value>(actual_policy).expect("parse expected policy")
|
||||
);
|
||||
assert!(decoded.for_existing_account().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_service_account_envelope_rejects_missing_policy() {
|
||||
let create = SRSvcAccCreate {
|
||||
parent: "openid=verified-parent".to_string(),
|
||||
access_key: "OIDCREPLICATEDSERVICE".to_string(),
|
||||
secret_key: "oidcReplicatedSecret123".to_string(),
|
||||
groups: Vec::new(),
|
||||
claims: HashMap::from([(OIDC_VIRTUAL_PARENT_CLAIM.to_string(), Value::String("openid=verified-parent".to_string()))]),
|
||||
session_policy: SRSessionPolicy::default(),
|
||||
status: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
expiration: None,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
};
|
||||
|
||||
let envelope = rustfs_madmin::SRSvcAccReplicationEnvelope {
|
||||
version: SERVICE_ACCOUNT_ENVELOPE_VERSION,
|
||||
};
|
||||
let err = decode_service_account_replication_policy(&create, Some(&envelope), Some(OffsetDateTime::UNIX_EPOCH), None)
|
||||
.expect_err("policy-less envelope must fail closed");
|
||||
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidRequest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_oidc_service_account_envelope_is_ignored_before_decoding() {
|
||||
let create = SRSvcAccCreate {
|
||||
parent: "openid=verified-parent".to_string(),
|
||||
access_key: "OIDCREPLICATEDSERVICE".to_string(),
|
||||
secret_key: "oidcReplicatedSecret123".to_string(),
|
||||
groups: Vec::new(),
|
||||
claims: HashMap::new(),
|
||||
session_policy: SRSessionPolicy::default(),
|
||||
status: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
expiration: None,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
};
|
||||
|
||||
let envelope = rustfs_madmin::SRSvcAccReplicationEnvelope {
|
||||
version: SERVICE_ACCOUNT_ENVELOPE_VERSION + 1,
|
||||
};
|
||||
let decoded = decode_service_account_replication_policy(
|
||||
&create,
|
||||
Some(&envelope),
|
||||
Some(OffsetDateTime::UNIX_EPOCH),
|
||||
Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1)),
|
||||
)
|
||||
.expect("stale envelope must be ignored before validation");
|
||||
|
||||
assert!(decoded.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_service_account_envelope_does_not_survive_a_legacy_hop() {
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct LegacyServiceAccountChange {
|
||||
#[serde(rename = "crSvcAccCreate", skip_serializing_if = "Option::is_none")]
|
||||
create: Option<SRSvcAccCreate>,
|
||||
#[serde(rename = "apiVersion", skip_serializing_if = "Option::is_none")]
|
||||
api_version: Option<String>,
|
||||
}
|
||||
|
||||
let claims =
|
||||
HashMap::from([(OIDC_VIRTUAL_PARENT_CLAIM.to_string(), Value::String("openid=verified-parent".to_string()))]);
|
||||
let (session_policy, envelope) =
|
||||
encode_service_account_replication_policy(&claims, None).expect("encode envelope for legacy hop");
|
||||
let change = rustfs_madmin::SRSvcAccChange {
|
||||
create: Some(SRSvcAccCreate {
|
||||
parent: "openid=verified-parent".to_string(),
|
||||
access_key: "OIDCREPLICATEDSERVICE".to_string(),
|
||||
secret_key: "oidcReplicatedSecret123".to_string(),
|
||||
groups: Vec::new(),
|
||||
claims,
|
||||
session_policy,
|
||||
status: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
expiration: None,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}),
|
||||
oidc_service_account_envelope: envelope,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let legacy: LegacyServiceAccountChange =
|
||||
serde_json::from_value(serde_json::to_value(change).expect("serialize new replication payload"))
|
||||
.expect("legacy node must ignore the unknown envelope field");
|
||||
let legacy_claims = legacy
|
||||
.create
|
||||
.as_ref()
|
||||
.expect("legacy payload has a create operation")
|
||||
.claims
|
||||
.clone();
|
||||
assert_eq!(legacy_claims.len(), 1);
|
||||
|
||||
let reemitted: rustfs_madmin::SRSvcAccChange = serde_json::from_value(
|
||||
serde_json::to_value(LegacyServiceAccountChange {
|
||||
create: Some(SRSvcAccCreate {
|
||||
parent: "openid=verified-parent".to_string(),
|
||||
access_key: "OIDCLEGACYCHILD001".to_string(),
|
||||
secret_key: "oidcLegacyChildSecret123".to_string(),
|
||||
groups: Vec::new(),
|
||||
claims: legacy_claims,
|
||||
session_policy: SRSessionPolicy::default(),
|
||||
status: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
expiration: None,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
})
|
||||
.expect("serialize legacy child replication payload"),
|
||||
)
|
||||
.expect("new node accepts legacy child replication payload");
|
||||
|
||||
assert!(reemitted.oidc_service_account_envelope.is_none());
|
||||
let create = reemitted.create.expect("reemitted payload has a create operation");
|
||||
let decoded = decode_service_account_replication_policy(&create, None, Some(OffsetDateTime::UNIX_EPOCH), None)
|
||||
.expect("legacy payload must not be parsed as an envelope")
|
||||
.expect("legacy payload should be accepted");
|
||||
assert!(!decoded.is_envelope);
|
||||
}
|
||||
|
||||
fn valid_test_ca_pem(name: &str) -> String {
|
||||
rcgen::generate_simple_self_signed(vec![name.to_string()])
|
||||
.expect("generate test CA")
|
||||
|
||||
@@ -1078,7 +1078,7 @@ impl Operation for ImportIam {
|
||||
if let Some(file_content) = file_content {
|
||||
let svc_accts: HashMap<String, SRSvcAccCreate> = serde_json::from_slice(&file_content)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, e.to_string()))?;
|
||||
for (ak, req) in svc_accts {
|
||||
for (ak, mut req) in svc_accts {
|
||||
if skipped.service_accounts.contains(&ak) {
|
||||
continue;
|
||||
}
|
||||
@@ -1123,6 +1123,9 @@ impl Operation for ImportIam {
|
||||
})?;
|
||||
}
|
||||
|
||||
if !owner {
|
||||
let _ = rustfs_iam::sys::remove_verified_federated_policy(&mut req.claims);
|
||||
}
|
||||
let opts = NewServiceAccountOpts {
|
||||
session_policy: sp,
|
||||
access_key: ak.clone(),
|
||||
|
||||
@@ -18,9 +18,9 @@ use crate::admin::{
|
||||
runtime_sources::{current_action_credentials, current_ready_iam_handle, current_token_signing_key},
|
||||
};
|
||||
use rustfs_iam::{
|
||||
federation::{FederatedSessionBinding, FederatedSessionBindingError, FederatedSessionTransaction},
|
||||
store::object::ObjectStore,
|
||||
sys::IamSys,
|
||||
federation::{FederatedSessionBinding, FederatedSessionBindingError, FederatedSessionTransaction, OIDC_VIRTUAL_PARENT_CLAIM},
|
||||
store::{MappedPolicy, object::ObjectStore},
|
||||
sys::{IamSys, is_safe_claim_policy_name},
|
||||
};
|
||||
use rustfs_madmin::{SITE_REPL_API_VERSION, SRIAMItem, SRSTSCredential};
|
||||
use rustfs_policy::auth::get_new_credentials_with_metadata;
|
||||
@@ -32,6 +32,17 @@ use tracing::{debug, warn};
|
||||
|
||||
pub(crate) struct DefaultFederatedSessionBinding;
|
||||
|
||||
// Legacy receivers reject an empty parsed mapping; current receivers ignore it when the virtual-parent marker is present.
|
||||
const OIDC_STS_REQUIRES_VIRTUAL_PARENT_RECEIVER_POLICY: &str = " ";
|
||||
|
||||
fn all_oidc_policies_resolved(selected_policy_names: &[String], resolved_policy_mapping: &str) -> bool {
|
||||
let resolved_policy_names = MappedPolicy::new(resolved_policy_mapping).to_slice();
|
||||
!selected_policy_names.is_empty()
|
||||
&& selected_policy_names
|
||||
.iter()
|
||||
.all(|policy_name| is_safe_claim_policy_name(policy_name) && resolved_policy_names.contains(policy_name))
|
||||
}
|
||||
|
||||
fn build_oidc_token_claims(transaction: &FederatedSessionTransaction) -> HashMap<String, Value> {
|
||||
let authorization = &transaction.authorization;
|
||||
let claims = &authorization.claims;
|
||||
@@ -168,22 +179,23 @@ fn parent_user_is_reserved(parent_user: &str, root_access_key: Option<&str>) ->
|
||||
|
||||
fn issue_credentials(
|
||||
transaction: &FederatedSessionTransaction,
|
||||
selected_policy_names: &[String],
|
||||
secret: Option<&str>,
|
||||
) -> Result<rustfs_credentials::Credentials, FederatedSessionBindingError> {
|
||||
let authorization = &transaction.authorization;
|
||||
let claims = &authorization.claims;
|
||||
let parent_user = authorization.oidc_virtual_parent().ok_or_else(|| {
|
||||
FederatedSessionBindingError::InvalidRequest("verified OIDC identity is missing issuer or subject".to_string())
|
||||
})?;
|
||||
let mut token_claims = build_oidc_token_claims(transaction);
|
||||
let duration = i64::try_from(transaction.duration_seconds)
|
||||
.map_err(|_| FederatedSessionBindingError::InvalidRequest("invalid duration".to_string()))?;
|
||||
let exp = OffsetDateTime::now_utc().saturating_add(Duration::seconds(duration));
|
||||
token_claims.insert("exp".to_string(), Value::Number(serde_json::Number::from(exp.unix_timestamp())));
|
||||
|
||||
let parent_user = claims.session_identity();
|
||||
// Fail closed if the derived federated parent collides with the root access key. At IAM
|
||||
// request time `parent_user == root access key` is treated as owner (see auth.rs and
|
||||
// rustfs_iam owner resolution), so issuing such a credential would silently grant a
|
||||
// federated identity full owner access purely because its display name matched root.
|
||||
// Deny before any credential generation, `set_temp_user`, or site replication.
|
||||
// request time `parent_user == root access key` is treated as owner, so issuing such a
|
||||
// credential would silently grant a federated identity full owner access.
|
||||
let root_access_key = current_action_credentials().map(|cred| cred.access_key);
|
||||
if parent_user_is_reserved(&parent_user, root_access_key.as_deref()) {
|
||||
return Err(FederatedSessionBindingError::InvalidRequest(
|
||||
@@ -205,9 +217,12 @@ fn issue_credentials(
|
||||
"OIDC STS credential claims prepared"
|
||||
);
|
||||
token_claims.insert("parent".to_string(), Value::String(parent_user.clone()));
|
||||
token_claims.insert(OIDC_VIRTUAL_PARENT_CLAIM.to_string(), Value::String(parent_user.clone()));
|
||||
|
||||
if !authorization.policies.is_empty() {
|
||||
token_claims.insert("policy".to_string(), Value::String(authorization.policies.join(",")));
|
||||
if !selected_policy_names.is_empty() {
|
||||
token_claims.insert("policy".to_string(), Value::String(selected_policy_names.join(",")));
|
||||
} else {
|
||||
token_claims.remove("policy");
|
||||
}
|
||||
if let Some(policy) = transaction.session_policy.as_deref() {
|
||||
populate_session_policy(&mut token_claims, policy).map_err(binding_error_from_s3)?;
|
||||
@@ -221,11 +236,7 @@ fn issue_credentials(
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
fn site_replication_item(
|
||||
credentials: &rustfs_credentials::Credentials,
|
||||
transaction: &FederatedSessionTransaction,
|
||||
updated_at: OffsetDateTime,
|
||||
) -> SRIAMItem {
|
||||
fn site_replication_item(credentials: &rustfs_credentials::Credentials, updated_at: OffsetDateTime) -> SRIAMItem {
|
||||
SRIAMItem {
|
||||
r#type: "sts-credential".to_string(),
|
||||
sts_credential: Some(SRSTSCredential {
|
||||
@@ -233,7 +244,7 @@ fn site_replication_item(
|
||||
secret_key: credentials.secret_key.clone(),
|
||||
session_token: credentials.session_token.clone(),
|
||||
parent_user: credentials.parent_user.clone(),
|
||||
parent_policy_mapping: transaction.authorization.policies.join(","),
|
||||
parent_policy_mapping: OIDC_STS_REQUIRES_VIRTUAL_PARENT_RECEIVER_POLICY.to_string(),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}),
|
||||
updated_at: Some(updated_at),
|
||||
@@ -249,17 +260,35 @@ impl FederatedSessionBinding for DefaultFederatedSessionBinding {
|
||||
transaction: &FederatedSessionTransaction,
|
||||
) -> Result<rustfs_credentials::Credentials, FederatedSessionBindingError> {
|
||||
let authorization = &transaction.authorization;
|
||||
let secret = current_token_signing_key();
|
||||
let credentials = issue_credentials(transaction, secret.as_deref())?;
|
||||
|
||||
let iam_store =
|
||||
current_ready_iam_handle().map_err(|_| FederatedSessionBindingError::Internal("IAM not initialized".to_string()))?;
|
||||
let parent_user = authorization.oidc_virtual_parent().ok_or_else(|| {
|
||||
FederatedSessionBindingError::InvalidRequest("verified OIDC identity is missing issuer or subject".to_string())
|
||||
})?;
|
||||
let selected_policy_names = match iam_store
|
||||
.policy_db_get(&parent_user, &Some(authorization.groups.clone()))
|
||||
.await
|
||||
.map_err(|_| FederatedSessionBindingError::Internal("failed to resolve OIDC policy mapping".to_string()))?
|
||||
{
|
||||
mapped_policy_names if !mapped_policy_names.is_empty() => mapped_policy_names,
|
||||
_ => authorization.policies.clone(),
|
||||
};
|
||||
let selected_policy_mapping = selected_policy_names.join(",");
|
||||
let resolved_policy_mapping = iam_store.current_policies(&selected_policy_mapping).await;
|
||||
if !all_oidc_policies_resolved(&selected_policy_names, &resolved_policy_mapping) {
|
||||
return Err(FederatedSessionBindingError::InvalidRequest(
|
||||
"OIDC policy mapping did not resolve to current policies".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let secret = current_token_signing_key();
|
||||
let credentials = issue_credentials(transaction, &selected_policy_names, secret.as_deref())?;
|
||||
if tracing::enabled!(tracing::Level::DEBUG) {
|
||||
log_oidc_policy_diagnostics(
|
||||
&iam_store,
|
||||
&authorization.provider_id,
|
||||
&credentials.parent_user,
|
||||
&authorization.policies,
|
||||
&selected_policy_names,
|
||||
&authorization.groups,
|
||||
)
|
||||
.await;
|
||||
@@ -270,7 +299,7 @@ impl FederatedSessionBinding for DefaultFederatedSessionBinding {
|
||||
.await
|
||||
.map_err(|_| FederatedSessionBindingError::Internal("failed to store temp user".to_string()))?;
|
||||
|
||||
if let Err(err) = site_replication_iam_change_hook(site_replication_item(&credentials, transaction, updated_at)).await {
|
||||
if let Err(err) = site_replication_iam_change_hook(site_replication_item(&credentials, updated_at)).await {
|
||||
warn!("site replication OIDC STS hook failed, err: {err}");
|
||||
}
|
||||
|
||||
@@ -292,7 +321,7 @@ mod tests {
|
||||
email: "user@example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
groups: vec!["source-group".to_string()],
|
||||
raw: HashMap::new(),
|
||||
raw: HashMap::from([("iss".to_string(), serde_json::json!("https://idp.example.test"))]),
|
||||
},
|
||||
policies: vec!["readwrite".to_string()],
|
||||
groups: vec!["devs".to_string()],
|
||||
@@ -322,23 +351,32 @@ mod tests {
|
||||
fn issued_credentials_and_replication_item_preserve_existing_shape() {
|
||||
let transaction = transaction();
|
||||
let secret = "federated-session-test-signing-secret";
|
||||
let selected_policy_names = vec!["readonly".to_string()];
|
||||
|
||||
let credentials = issue_credentials(&transaction, Some(secret)).expect("credential issuance should succeed");
|
||||
assert_eq!(credentials.parent_user, "user");
|
||||
let credentials =
|
||||
issue_credentials(&transaction, &selected_policy_names, Some(secret)).expect("credential issuance should succeed");
|
||||
assert_eq!(credentials.parent_user, "openid=pUmguI1petsjVfDFQppmmR9yqdmWnBAXGJhHV_s9W3I");
|
||||
assert_eq!(credentials.groups, Some(vec!["devs".to_string()]));
|
||||
|
||||
let claims = rustfs_iam::sys::get_claims_from_token_with_secret(&credentials.session_token, secret)
|
||||
.expect("issued session token should verify");
|
||||
assert_eq!(claims.get("iss"), Some(&serde_json::json!("rustfs-oidc")));
|
||||
assert_eq!(claims.get("oidc_provider"), Some(&serde_json::json!("default")));
|
||||
assert_eq!(claims.get("parent"), Some(&serde_json::json!("user")));
|
||||
assert_eq!(claims.get("policy"), Some(&serde_json::json!("readwrite")));
|
||||
assert_eq!(
|
||||
claims.get("parent"),
|
||||
Some(&serde_json::json!("openid=pUmguI1petsjVfDFQppmmR9yqdmWnBAXGJhHV_s9W3I"))
|
||||
);
|
||||
assert_eq!(
|
||||
claims.get(OIDC_VIRTUAL_PARENT_CLAIM),
|
||||
Some(&serde_json::json!("openid=pUmguI1petsjVfDFQppmmR9yqdmWnBAXGJhHV_s9W3I"))
|
||||
);
|
||||
assert_eq!(claims.get("policy"), Some(&serde_json::json!("readonly")));
|
||||
assert_eq!(claims.get("groups"), Some(&serde_json::json!(["devs"])));
|
||||
assert_eq!(claims.get("roles"), Some(&serde_json::json!(["admin", "reader"])));
|
||||
assert!(!claims.contains_key("oidc_issuer"));
|
||||
|
||||
let updated_at = OffsetDateTime::UNIX_EPOCH;
|
||||
let item = site_replication_item(&credentials, &transaction, updated_at);
|
||||
let item = site_replication_item(&credentials, updated_at);
|
||||
assert_eq!(item.r#type, "sts-credential");
|
||||
assert_eq!(item.updated_at, Some(updated_at));
|
||||
assert_eq!(item.api_version.as_deref(), Some(SITE_REPL_API_VERSION));
|
||||
@@ -346,8 +384,10 @@ mod tests {
|
||||
assert_eq!(replicated.access_key, credentials.access_key);
|
||||
assert_eq!(replicated.secret_key, credentials.secret_key);
|
||||
assert_eq!(replicated.session_token, credentials.session_token);
|
||||
assert_eq!(replicated.parent_user, "user");
|
||||
assert_eq!(replicated.parent_policy_mapping, "readwrite");
|
||||
assert_eq!(replicated.parent_user, "openid=pUmguI1petsjVfDFQppmmR9yqdmWnBAXGJhHV_s9W3I");
|
||||
assert_eq!(replicated.parent_policy_mapping, OIDC_STS_REQUIRES_VIRTUAL_PARENT_RECEIVER_POLICY);
|
||||
assert!(replicated.parent_policy_mapping.trim().is_empty());
|
||||
assert!(MappedPolicy::new(&replicated.parent_policy_mapping).to_slice().is_empty());
|
||||
assert_eq!(replicated.api_version.as_deref(), Some(SITE_REPL_API_VERSION));
|
||||
}
|
||||
|
||||
@@ -356,7 +396,8 @@ mod tests {
|
||||
let mut transaction = transaction();
|
||||
transaction.session_policy = Some("not-json".to_string());
|
||||
|
||||
let error = issue_credentials(&transaction, None).expect_err("invalid policy should fail first");
|
||||
let error = issue_credentials(&transaction, &transaction.authorization.policies, None)
|
||||
.expect_err("invalid policy should fail first");
|
||||
assert!(matches!(error, FederatedSessionBindingError::InvalidRequest(_)));
|
||||
}
|
||||
|
||||
@@ -377,9 +418,29 @@ mod tests {
|
||||
// Reuse the collision decision the issuance path applies: a federated identity whose
|
||||
// derived parent_user equals the root access key must be denied at issuance.
|
||||
let transaction = transaction();
|
||||
let parent_user = transaction.authorization.claims.session_identity();
|
||||
assert_eq!(parent_user, "user");
|
||||
assert!(parent_user_is_reserved(&parent_user, Some("user")));
|
||||
let parent_user = transaction
|
||||
.authorization
|
||||
.oidc_virtual_parent()
|
||||
.expect("fixture must contain issuer and subject");
|
||||
assert!(parent_user_is_reserved(&parent_user, Some(&parent_user)));
|
||||
assert!(!parent_user_is_reserved(&parent_user, Some("root")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_oidc_issuer_fails_closed_before_credential_issuance() {
|
||||
let mut transaction = transaction();
|
||||
transaction.authorization.claims.raw.clear();
|
||||
|
||||
let error = issue_credentials(&transaction, &transaction.authorization.policies, Some("signing-secret"))
|
||||
.expect_err("credential issuance should reject a missing issuer");
|
||||
assert!(matches!(error, FederatedSessionBindingError::InvalidRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_replication_requires_all_selected_policies() {
|
||||
assert!(all_oidc_policies_resolved(&["readonly".to_string()], "readonly"));
|
||||
assert!(!all_oidc_policies_resolved(&["readonly".to_string(), "missing".to_string()], "readonly"));
|
||||
assert!(!all_oidc_policies_resolved(&[], ""));
|
||||
assert!(!all_oidc_policies_resolved(&["team+readonly".to_string()], "team+readonly"));
|
||||
}
|
||||
}
|
||||
|
||||
+29
-10
@@ -246,6 +246,14 @@ pub async fn check_key_valid(session_token: &str, access_key: &str) -> S3Result<
|
||||
check_key_valid_with_context(session_token, access_key, None).await
|
||||
}
|
||||
|
||||
fn has_root_access(sys_cred: &Credentials, cred: &Credentials) -> bool {
|
||||
(constant_time_eq(&sys_cred.access_key, &cred.access_key) || constant_time_eq(&cred.parent_user, &sys_cred.access_key))
|
||||
&& !cred
|
||||
.claims
|
||||
.as_ref()
|
||||
.is_some_and(|claims| claims.contains_key(SESSION_POLICY_NAME) || rustfs_iam::sys::is_rustfs_oidc_claims(claims))
|
||||
}
|
||||
|
||||
/// Validate an access key, resolving the root credentials and IAM system from
|
||||
/// an explicit application context when one is given (backlog#1052 S6).
|
||||
///
|
||||
@@ -427,16 +435,7 @@ pub async fn check_key_valid_with_context(
|
||||
|
||||
cred.claims = if !claims.is_empty() { Some(claims) } else { None };
|
||||
|
||||
let mut owner =
|
||||
constant_time_eq(&sys_cred.access_key, &cred.access_key) || constant_time_eq(&cred.parent_user, &sys_cred.access_key);
|
||||
|
||||
// permitRootAccess
|
||||
if let Some(claims) = &cred.claims
|
||||
&& claims.contains_key(SESSION_POLICY_NAME)
|
||||
{
|
||||
owner = false
|
||||
}
|
||||
|
||||
let owner = has_root_access(&sys_cred, &cred);
|
||||
Ok((cred, owner))
|
||||
}
|
||||
|
||||
@@ -1080,6 +1079,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_session_cannot_inherit_root_access_from_display_name() {
|
||||
let sys_cred = Credentials {
|
||||
access_key: "root-access-key".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let oidc_cred = Credentials {
|
||||
access_key: "temporary-access-key".to_string(),
|
||||
parent_user: sys_cred.access_key.clone(),
|
||||
claims: Some(HashMap::from([
|
||||
("iss".to_string(), json!("rustfs-oidc")),
|
||||
("oidc_provider".to_string(), json!("default")),
|
||||
("sub".to_string(), json!("subject-123")),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!has_root_access(&sys_cred, &oidc_cred));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iam_auth_creation() {
|
||||
let access_key = "test-access-key";
|
||||
|
||||
@@ -1380,13 +1380,20 @@ impl Node for NodeService {
|
||||
error_info: Some("access_key name is missing".to_string()),
|
||||
}));
|
||||
}
|
||||
let Some(iam_sys) = runtime_sources::current_iam_handle() else {
|
||||
let Some(iam_sys) = self
|
||||
.context
|
||||
.as_ref()
|
||||
.map(|context| context.iam().handle())
|
||||
.or_else(runtime_sources::current_iam_handle)
|
||||
else {
|
||||
return Ok(Response::new(DeleteServiceAccountResponse {
|
||||
success: false,
|
||||
error_info: Some("errServerNotInitialized".to_string()),
|
||||
}));
|
||||
};
|
||||
let resp = iam_sys.delete_service_account(&access_key, false).await;
|
||||
// This legacy RPC is a cache notification. Reloading shared state keeps a
|
||||
// delayed delete notification from removing a recreated service account.
|
||||
let resp = iam_sys.load_service_account(&access_key).await;
|
||||
if let Err(err) = resp {
|
||||
return Ok(Response::new(DeleteServiceAccountResponse {
|
||||
success: false,
|
||||
@@ -1859,7 +1866,7 @@ mod tests {
|
||||
PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||
STORAGE_CLASS_SUB_SYS, admit_heal_control_replay, background_rebalance_start_error_message,
|
||||
execute_heal_control_envelope_with_manager, initialize_heal_topology_fingerprint, make_heal_control_server,
|
||||
make_heal_control_server_with_cache, make_server, make_tier_mutation_control_server_for_context,
|
||||
make_heal_control_server_with_cache, make_server, make_server_for_context, make_tier_mutation_control_server_for_context,
|
||||
remove_heal_control_replay, scanner_activity_response, stop_rebalance_response,
|
||||
};
|
||||
use crate::storage::rpc::node_service::heal::heal_topology_fingerprint;
|
||||
@@ -1871,6 +1878,14 @@ mod tests {
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use rustfs_heal::heal::{manager::HealManager, storage::HealStorageAPI};
|
||||
use rustfs_iam::{
|
||||
store::{
|
||||
Store as _,
|
||||
object::{IAM_CONFIG_PREFIX, ObjectStore},
|
||||
},
|
||||
sys::NewServiceAccountOpts,
|
||||
};
|
||||
use rustfs_kms::KmsServiceManager;
|
||||
use rustfs_protos::models::PingBodyBuilder;
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
BackgroundHealStatusRequest, CheckPartsRequest, DeleteBucketMetadataRequest, DeleteBucketRequest, DeletePathsRequest,
|
||||
@@ -4088,6 +4103,59 @@ mod tests {
|
||||
assert!(delete_response.error_info.unwrap().contains("access_key name is missing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_service_account_rpc_reloads_instead_of_deleting_shared_state() {
|
||||
let _ = rustfs_credentials::init_global_action_credentials(
|
||||
Some("TESTROOTACCESSKEY".to_string()),
|
||||
Some("TESTROOTSECRET123".to_string()),
|
||||
);
|
||||
let temp_dir = tempfile::tempdir().expect("service-account RPC test directory");
|
||||
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.base_dir(temp_dir.path())
|
||||
.init_bucket_metadata(false)
|
||||
.build()
|
||||
.await;
|
||||
ObjectStore::new(Arc::clone(&env.ecstore))
|
||||
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
|
||||
.await
|
||||
.expect("seed IAM format");
|
||||
let iam = rustfs_iam::build_iam_sys(Arc::clone(&env.ecstore))
|
||||
.await
|
||||
.expect("build isolated IAM");
|
||||
let context = Arc::new(crate::runtime_sources::AppContext::with_default_interfaces(
|
||||
Arc::clone(&env.ecstore),
|
||||
Arc::clone(&iam),
|
||||
Arc::new(KmsServiceManager::new()),
|
||||
));
|
||||
let service = make_server_for_context(Some(context));
|
||||
let access_key = "RPCRELOADSERVICE01";
|
||||
iam.new_service_account(
|
||||
"parent-user",
|
||||
None,
|
||||
NewServiceAccountOpts {
|
||||
access_key: access_key.to_string(),
|
||||
secret_key: "rpcReloadServiceSecret123".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create service account");
|
||||
|
||||
let response = service
|
||||
.delete_service_account(Request::new(DeleteServiceAccountRequest {
|
||||
access_key: access_key.to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("legacy notification RPC response")
|
||||
.into_inner();
|
||||
|
||||
assert!(response.success, "cache reload notification must succeed");
|
||||
assert!(
|
||||
iam.get_service_account(access_key).await.is_ok(),
|
||||
"legacy delete notification must not delete durable service-account state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_user_empty_access_key() {
|
||||
let service = create_test_node_service();
|
||||
|
||||
Reference in New Issue
Block a user