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
Generated
+1
View File
@@ -8928,6 +8928,7 @@ dependencies = [
"rustfs-signer",
"rustfs-storage-api",
"rustfs-targets",
"rustfs-test-utils",
"rustfs-tls-runtime",
"rustfs-trusted-proxies",
"rustfs-utils",
+1 -1
View File
@@ -47,7 +47,7 @@ base64-simd = { workspace = true }
jsonwebtoken = { workspace = true, features = ["aws_lc_rs"] }
tracing.workspace = true
rustfs-madmin.workspace = true
rustfs-utils = { workspace = true, features = ["path", "egress"] }
rustfs-utils = { workspace = true, features = ["egress", "hash", "path"] }
rustfs-io-metrics.workspace = true
tokio-util = { workspace = true, features = ["io", "compat"] }
pollster.workspace = true
+7
View File
@@ -25,6 +25,7 @@ use rustfs_policy::{
policy::{Args, PolicyDoc},
};
use time::OffsetDateTime;
use tokio::sync::Mutex as AsyncMutex;
use tracing::warn;
use crate::store::{GroupInfo, MappedPolicy};
@@ -63,6 +64,7 @@ impl Default for CacheState {
pub struct Cache {
state: ArcSwap<CacheState>,
write_lock: Mutex<()>,
service_account_mutation_lock: AsyncMutex<()>,
}
impl Default for Cache {
@@ -70,6 +72,7 @@ impl Default for Cache {
Self {
state: ArcSwap::new(Arc::new(CacheState::default())),
write_lock: Mutex::new(()),
service_account_mutation_lock: AsyncMutex::new(()),
}
}
}
@@ -77,6 +80,10 @@ impl Default for Cache {
pub(crate) type CacheSnapshot = Guard<Arc<CacheState>>;
impl Cache {
pub(crate) fn service_account_mutation_lock(&self) -> &AsyncMutex<()> {
&self.service_account_mutation_lock
}
pub(crate) fn snapshot(&self) -> CacheSnapshot {
self.state.load()
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub use binding::FederatedSessionBinding;
pub use error::{FederatedSessionBindingError, FederationError, Result};
pub use model::{
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedLoginSession, FederatedSession,
FederatedSessionTransaction,
FederatedSessionTransaction, OIDC_VIRTUAL_PARENT_CLAIM,
};
pub use provider::FederatedIdentityProvider;
pub use registry::FederatedIdentityRegistry;
+86 -1
View File
@@ -13,9 +13,13 @@
// limitations under the License.
use rustfs_credentials::Credentials;
use rustfs_utils::HashAlgorithm;
use serde_json::Value;
use std::collections::HashMap;
pub const OIDC_VIRTUAL_PARENT_CLAIM: &str = "x-rustfs-internal-oidc-parent";
const OIDC_VIRTUAL_PARENT_PREFIX: &str = "openid=";
#[derive(Debug, Clone)]
pub struct FederatedClaims {
pub sub: String,
@@ -53,6 +57,27 @@ impl FederatedAuthorization {
pub fn has_authorization_context(&self) -> bool {
!self.policies.is_empty() || !self.groups.is_empty()
}
pub fn oidc_virtual_parent(&self) -> Option<String> {
let issuer = self.claims.raw.get("iss")?.as_str()?;
let subject = self.claims.sub.as_str();
if issuer.is_empty() || subject.is_empty() {
return None;
}
let subject_len = u64::try_from(subject.len()).ok()?;
let issuer_len = u64::try_from(issuer.len()).ok()?;
let mut source = Vec::with_capacity(16 + subject.len() + issuer.len());
source.extend_from_slice(&subject_len.to_be_bytes());
source.extend_from_slice(subject.as_bytes());
source.extend_from_slice(&issuer_len.to_be_bytes());
source.extend_from_slice(issuer.as_bytes());
let digest = HashAlgorithm::SHA256.hash_encode(&source);
Some(format!(
"{OIDC_VIRTUAL_PARENT_PREFIX}{}",
base64_simd::URL_SAFE_NO_PAD.encode_to_string(digest.as_ref())
))
}
}
#[derive(Debug)]
@@ -99,7 +124,10 @@ mod tests {
fn authorization(policies: Vec<String>, groups: Vec<String>) -> FederatedAuthorization {
FederatedAuthorization {
provider_id: "standard_oidc".to_string(),
claims: claims("", "", "subject"),
claims: FederatedClaims {
raw: HashMap::from([("iss".to_string(), Value::String("https://idp.example.test".to_string()))]),
..claims("", "", "subject")
},
policies,
groups,
roles_claim_key: None,
@@ -121,4 +149,61 @@ mod tests {
assert!(authorization(vec!["consoleAdmin".to_string()], Vec::new()).has_authorization_context());
assert!(authorization(Vec::new(), vec!["RustFS.ConsoleAdmin".to_string()]).has_authorization_context());
}
#[test]
fn oidc_virtual_parent_is_stable_and_issuer_scoped() {
let first = authorization(Vec::new(), Vec::new());
let mut second = first.clone();
second
.claims
.raw
.insert("iss".to_string(), Value::String("https://other-idp.example.test".to_string()));
assert_eq!(
first.oidc_virtual_parent().as_deref(),
Some("openid=pUmguI1petsjVfDFQppmmR9yqdmWnBAXGJhHV_s9W3I")
);
assert!(rustfs_policy::auth::contains_reserved_chars(
first.oidc_virtual_parent().as_deref().expect("virtual parent")
));
assert_ne!(first.oidc_virtual_parent(), second.oidc_virtual_parent());
}
#[test]
fn oidc_virtual_parent_requires_verified_identity_parts() {
let mut missing_issuer = authorization(Vec::new(), Vec::new());
missing_issuer.claims.raw.clear();
let mut missing_subject = authorization(Vec::new(), Vec::new());
missing_subject.claims.sub.clear();
assert!(missing_issuer.oidc_virtual_parent().is_none());
assert!(missing_subject.oidc_virtual_parent().is_none());
}
#[test]
fn oidc_virtual_parent_preserves_exact_subject() {
let plain = authorization(Vec::new(), Vec::new());
let mut padded = plain.clone();
padded.claims.sub = format!(" {} ", plain.claims.sub);
assert_ne!(plain.oidc_virtual_parent(), padded.oidc_virtual_parent());
}
#[test]
fn oidc_virtual_parent_encoding_is_unambiguous() {
let mut first = authorization(Vec::new(), Vec::new());
first.claims.sub = "subject".to_string();
first.claims.raw.insert(
"iss".to_string(),
Value::String("https://issuer.example/path:https://other.example".to_string()),
);
let mut second = authorization(Vec::new(), Vec::new());
second.claims.sub = "subject:https://issuer.example/path".to_string();
second
.claims
.raw
.insert("iss".to_string(), Value::String("https://other.example".to_string()));
assert_ne!(first.oidc_virtual_parent(), second.oidc_virtual_parent());
}
}
-12
View File
@@ -126,18 +126,6 @@ pub(crate) async fn notify_iam_load_service_account(access_key: &str) -> Vec<Iam
}
}
pub(crate) async fn notify_iam_delete_service_account(access_key: &str) -> Vec<IamNotificationPeerErr> {
match runtime_sources::notification_sys() {
Some(notification_sys) => notification_sys
.delete_service_account(access_key)
.await
.into_iter()
.map(Into::into)
.collect(),
None => Vec::new(),
}
}
pub(crate) async fn notify_iam_load_group(group: &str) -> Vec<IamNotificationPeerErr> {
match runtime_sources::notification_sys() {
Some(notification_sys) => notification_sys.load_group(group).await.into_iter().map(Into::into).collect(),
+226 -11
View File
@@ -782,6 +782,7 @@ where
return Err(Error::InvalidArgument);
}
let _mutation_guard = self.cache.service_account_mutation_lock().lock().await;
let cache = self.cache.snapshot();
if cache.users.contains_key(&cred.access_key) || cache.sts_accounts.contains_key(&cred.access_key) {
return Err(Error::AccessKeyAlreadyExists);
@@ -800,6 +801,7 @@ where
}
pub async fn update_service_account(&self, name: &str, opts: UpdateServiceAccountOpts) -> Result<OffsetDateTime> {
let _mutation_guard = self.cache.service_account_mutation_lock().lock().await;
let cache = self.cache.snapshot();
let Some(ui) = cache.users.get(name).cloned() else {
return Err(Error::NoSuchServiceAccount(name.to_string()));
@@ -891,12 +893,13 @@ where
cr.session_token = jwt_sign(&m, &cr.secret_key)?;
let u = UserIdentity::new(cr);
let updated_at = u.update_at.unwrap_or_else(OffsetDateTime::now_utc);
self.api
.save_user_identity(&u.credentials.access_key, UserType::Svc, u.clone(), None)
.await?;
self.update_user_with_claims(&u.credentials.access_key, u.clone())?;
Ok(OffsetDateTime::now_utc())
Ok(updated_at)
}
pub async fn policy_db_get(&self, name: &str, groups: &Option<Vec<String>>) -> Result<Vec<String>> {
@@ -1361,10 +1364,25 @@ where
}
pub async fn delete_user(&self, access_key: &str, utype: UserType) -> Result<()> {
self.delete_user_with_revision(access_key, utype).await?;
Ok(())
}
pub async fn delete_service_account_with_revision(&self, access_key: &str) -> Result<OffsetDateTime> {
self.delete_user_with_revision(access_key, UserType::Svc).await
}
async fn delete_user_with_revision(&self, access_key: &str, utype: UserType) -> Result<OffsetDateTime> {
if access_key.is_empty() {
return Err(Error::InvalidArgument);
}
let _service_account_guard = if utype == UserType::Svc {
Some(self.cache.service_account_mutation_lock().lock().await)
} else {
None
};
if utype == UserType::Reg {
let cache = self.cache.snapshot();
let member_of = cache.user_group_memberships.get(access_key).cloned();
@@ -1429,15 +1447,15 @@ where
return Err(err);
}
let deleted_at = OffsetDateTime::now_utc();
self.cache.with_write_lock(|cache| {
let now = OffsetDateTime::now_utc();
if utype == UserType::Sts {
cache.delete_sts_account(access_key, now);
cache.delete_sts_account(access_key, deleted_at);
}
cache.delete_user(access_key, now);
cache.delete_user(access_key, deleted_at);
});
Ok(())
Ok(deleted_at)
}
pub async fn update_user_secret_key(&self, access_key: &str, secret_key: &str) -> Result<()> {
@@ -1958,6 +1976,11 @@ where
Ok(())
}
pub async fn user_notification_handler(&self, name: &str, user_type: UserType) -> Result<()> {
let _service_account_guard = if user_type == UserType::Svc {
Some(self.cache.service_account_mutation_lock().lock().await)
} else {
None
};
let mut m = HashMap::new();
if let Err(err) = self.api.load_user_no_lock(name, user_type, &mut m).await {
if !is_err_no_such_user(&err) {
@@ -2222,9 +2245,10 @@ mod tests {
collections::HashMap,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
},
};
use tokio::sync::Notify;
#[derive(Clone)]
struct FailingInitialLoadStore;
@@ -2357,6 +2381,12 @@ mod tests {
saved_user: Arc<Mutex<Option<UserIdentity>>>,
load_attempts: Arc<AtomicUsize>,
visible_after_attempt: usize,
block_service_save: Arc<AtomicBool>,
service_save_started: Arc<Notify>,
release_service_save: Arc<Notify>,
block_service_load: Arc<AtomicBool>,
service_load_started: Arc<Notify>,
release_service_load: Arc<Notify>,
}
impl DelayedTempUserVisibilityStore {
@@ -2365,6 +2395,12 @@ mod tests {
saved_user: Arc::new(Mutex::new(None)),
load_attempts: Arc::new(AtomicUsize::new(0)),
visible_after_attempt,
block_service_save: Arc::new(AtomicBool::new(false)),
service_save_started: Arc::new(Notify::new()),
release_service_save: Arc::new(Notify::new()),
block_service_load: Arc::new(AtomicBool::new(false)),
service_load_started: Arc::new(Notify::new()),
release_service_load: Arc::new(Notify::new()),
}
}
}
@@ -2390,16 +2426,21 @@ mod tests {
async fn save_user_identity(
&self,
_name: &str,
_user_type: UserType,
user_type: UserType,
item: UserIdentity,
_ttl: Option<usize>,
) -> Result<()> {
if user_type == UserType::Svc && self.block_service_save.load(Ordering::SeqCst) {
self.service_save_started.notify_one();
self.release_service_save.notified().await;
}
*self.saved_user.lock().expect("saved_user mutex poisoned") = Some(item);
Ok(())
}
async fn delete_user_identity(&self, _name: &str, _user_type: UserType) -> Result<()> {
Err(Error::InvalidArgument)
*self.saved_user.lock().expect("saved_user mutex poisoned") = None;
Ok(())
}
async fn load_user_identity(&self, name: &str, _user_type: UserType) -> Result<UserIdentity> {
@@ -2415,8 +2456,19 @@ mod tests {
.ok_or_else(|| Error::NoSuchUser(name.to_string()))
}
async fn load_user(&self, _name: &str, _user_type: UserType, _m: &mut HashMap<String, UserIdentity>) -> Result<()> {
Err(Error::InvalidArgument)
async fn load_user(&self, name: &str, user_type: UserType, m: &mut HashMap<String, UserIdentity>) -> Result<()> {
let loaded = self
.saved_user
.lock()
.expect("saved_user mutex poisoned")
.clone()
.ok_or_else(|| Error::NoSuchUser(name.to_string()))?;
if user_type == UserType::Svc && self.block_service_load.load(Ordering::SeqCst) {
self.service_load_started.notify_one();
self.release_service_load.notified().await;
}
m.insert(name.to_string(), loaded);
Ok(())
}
async fn load_users(&self, _user_type: UserType, _m: &mut HashMap<String, UserIdentity>) -> Result<()> {
@@ -2485,7 +2537,7 @@ mod tests {
_is_group: bool,
_m: &mut HashMap<String, MappedPolicy>,
) -> Result<()> {
Err(Error::InvalidArgument)
Err(Error::NoSuchPolicy)
}
async fn load_mapped_policies(
@@ -2576,6 +2628,169 @@ mod tests {
);
}
#[tokio::test]
async fn service_account_notification_cannot_overwrite_concurrent_update() {
let _ = rustfs_credentials::init_global_action_credentials(
Some("TESTROOTACCESSKEY".to_string()),
Some("TESTROOTSECRET123".to_string()),
);
let store = DelayedTempUserVisibilityStore::new(0);
let cache = Arc::new(build_test_iam_cache(store.clone()));
let access_key = "SERIALIZEDSERVICE01";
let secret_key = "serializedServiceSecret123";
let metadata = HashMap::from([
("parent".to_string(), Value::String("parent-user".to_string())),
(iam_policy_claim_name_sa(), Value::String(INHERITED_POLICY_TYPE.to_string())),
]);
let credentials = Credentials {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
session_token: jwt_sign(&metadata, secret_key).expect("sign service account token"),
status: auth::ACCOUNT_ON.to_string(),
parent_user: "parent-user".to_string(),
claims: Some(metadata),
description: Some("old".to_string()),
..Default::default()
};
cache.add_service_account(credentials).await.expect("seed service account");
store.block_service_load.store(true, Ordering::SeqCst);
let notification = {
let cache = Arc::clone(&cache);
tokio::spawn(async move { cache.user_notification_handler(access_key, UserType::Svc).await })
};
store.service_load_started.notified().await;
let update = {
let cache = Arc::clone(&cache);
tokio::spawn(async move {
cache
.update_service_account(
access_key,
UpdateServiceAccountOpts {
session_policy: None,
secret_key: None,
name: None,
description: Some("new".to_string()),
expiration: None,
status: None,
allow_site_replicator_account: false,
},
)
.await
})
};
tokio::task::yield_now().await;
assert!(!update.is_finished(), "update must wait for the in-flight cache refresh");
store.release_service_load.notify_one();
notification.await.expect("notification task").expect("notification refresh");
update.await.expect("update task").expect("service account update");
let snapshot = cache.cache.snapshot();
assert_eq!(
snapshot
.users
.get(access_key)
.and_then(|identity| identity.credentials.description.as_deref()),
Some("new")
);
}
#[tokio::test]
async fn concurrent_service_account_create_cannot_overwrite_first_writer() {
let store = DelayedTempUserVisibilityStore::new(0);
store.block_service_save.store(true, Ordering::SeqCst);
let cache = Arc::new(build_test_iam_cache(store.clone()));
let access_key = "SERIALIZEDSERVICE00";
let credentials = |secret_key: &str| Credentials {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
status: auth::ACCOUNT_ON.to_string(),
parent_user: "parent-user".to_string(),
..Default::default()
};
let first = {
let cache = Arc::clone(&cache);
tokio::spawn(async move { cache.add_service_account(credentials("firstServiceSecret123")).await })
};
store.service_save_started.notified().await;
let second = {
let cache = Arc::clone(&cache);
tokio::spawn(async move { cache.add_service_account(credentials("secondServiceSecret234")).await })
};
tokio::task::yield_now().await;
assert!(!second.is_finished(), "second create must wait for the first writer");
store.block_service_save.store(false, Ordering::SeqCst);
store.release_service_save.notify_waiters();
first.await.expect("first create task").expect("first create");
let err = second
.await
.expect("second create task")
.expect_err("duplicate create must fail");
assert_eq!(err, Error::AccessKeyAlreadyExists);
assert_eq!(
cache
.cache
.snapshot()
.users
.get(access_key)
.map(|identity| identity.credentials.secret_key.as_str()),
Some("firstServiceSecret123")
);
}
#[tokio::test]
async fn service_account_notification_cannot_restore_concurrent_delete() {
let _ = rustfs_credentials::init_global_action_credentials(
Some("TESTROOTACCESSKEY".to_string()),
Some("TESTROOTSECRET123".to_string()),
);
let store = DelayedTempUserVisibilityStore::new(0);
let cache = Arc::new(build_test_iam_cache(store.clone()));
let access_key = "SERIALIZEDSERVICE02";
let secret_key = "serializedServiceSecret234";
let metadata = HashMap::from([
("parent".to_string(), Value::String("parent-user".to_string())),
(iam_policy_claim_name_sa(), Value::String(INHERITED_POLICY_TYPE.to_string())),
]);
let credentials = Credentials {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
session_token: jwt_sign(&metadata, secret_key).expect("sign service account token"),
status: auth::ACCOUNT_ON.to_string(),
parent_user: "parent-user".to_string(),
claims: Some(metadata),
..Default::default()
};
cache.add_service_account(credentials).await.expect("seed service account");
store.block_service_load.store(true, Ordering::SeqCst);
let notification = {
let cache = Arc::clone(&cache);
tokio::spawn(async move { cache.user_notification_handler(access_key, UserType::Svc).await })
};
store.service_load_started.notified().await;
let delete = {
let cache = Arc::clone(&cache);
tokio::spawn(async move { cache.delete_service_account_with_revision(access_key).await })
};
tokio::task::yield_now().await;
assert!(!delete.is_finished(), "delete must wait for the in-flight cache refresh");
store.release_service_load.notify_one();
notification.await.expect("notification task").expect("notification refresh");
delete.await.expect("delete task").expect("service account delete");
assert!(!cache.cache.snapshot().users.contains_key(access_key));
assert!(store.saved_user.lock().expect("saved_user mutex poisoned").is_none());
}
#[tokio::test]
async fn test_init_keeps_error_state_when_initial_load_fails() {
let (sender, receiver) = mpsc::channel::<i64>(1);
+834 -28
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -298,6 +298,11 @@ pub struct SRSvcAccDelete {
pub api_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SRSvcAccReplicationEnvelope {
pub version: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SRSvcAccChange {
#[serde(rename = "crSvcAccCreate", skip_serializing_if = "Option::is_none")]
@@ -306,6 +311,8 @@ pub struct SRSvcAccChange {
pub update: Option<SRSvcAccUpdate>,
#[serde(rename = "crSvcAccDelete", skip_serializing_if = "Option::is_none")]
pub delete: Option<SRSvcAccDelete>,
#[serde(rename = "oidcServiceAccountEnvelope", skip_serializing_if = "Option::is_none")]
pub oidc_service_account_envelope: Option<SRSvcAccReplicationEnvelope>,
#[serde(rename = "apiVersion", skip_serializing_if = "Option::is_none")]
pub api_version: Option<String>,
}
+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();