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:
GatewayJ
2026-07-24 17:41:52 +08:00
committed by GitHub
parent a5a73610b6
commit 1c88aa43c1
16 changed files with 1973 additions and 239 deletions
+1
View File
@@ -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 }
+192 -118
View File
@@ -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 {
+419 -20
View File
@@ -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")
+4 -1
View File
@@ -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(),
+94 -33
View File
@@ -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
View File
@@ -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";
+71 -3
View File
@@ -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();