mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-28 16:07:05 +00:00
fix(iam): virtualize OIDC service account parents (#5152)
* fix(iam): preserve OIDC service account policy boundary * fix(iam): virtualize OIDC service account parents * fix(iam): reject malformed OIDC policy boundaries * test(iam): isolate federated policy regression * fix(iam): keep OIDC replication envelope off claims
This commit is contained in:
Generated
+1
@@ -8928,6 +8928,7 @@ dependencies = [
|
|||||||
"rustfs-signer",
|
"rustfs-signer",
|
||||||
"rustfs-storage-api",
|
"rustfs-storage-api",
|
||||||
"rustfs-targets",
|
"rustfs-targets",
|
||||||
|
"rustfs-test-utils",
|
||||||
"rustfs-tls-runtime",
|
"rustfs-tls-runtime",
|
||||||
"rustfs-trusted-proxies",
|
"rustfs-trusted-proxies",
|
||||||
"rustfs-utils",
|
"rustfs-utils",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ base64-simd = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true, features = ["aws_lc_rs"] }
|
jsonwebtoken = { workspace = true, features = ["aws_lc_rs"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
rustfs-madmin.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
|
rustfs-io-metrics.workspace = true
|
||||||
tokio-util = { workspace = true, features = ["io", "compat"] }
|
tokio-util = { workspace = true, features = ["io", "compat"] }
|
||||||
pollster.workspace = true
|
pollster.workspace = true
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use rustfs_policy::{
|
|||||||
policy::{Args, PolicyDoc},
|
policy::{Args, PolicyDoc},
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::store::{GroupInfo, MappedPolicy};
|
use crate::store::{GroupInfo, MappedPolicy};
|
||||||
@@ -63,6 +64,7 @@ impl Default for CacheState {
|
|||||||
pub struct Cache {
|
pub struct Cache {
|
||||||
state: ArcSwap<CacheState>,
|
state: ArcSwap<CacheState>,
|
||||||
write_lock: Mutex<()>,
|
write_lock: Mutex<()>,
|
||||||
|
service_account_mutation_lock: AsyncMutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Cache {
|
impl Default for Cache {
|
||||||
@@ -70,6 +72,7 @@ impl Default for Cache {
|
|||||||
Self {
|
Self {
|
||||||
state: ArcSwap::new(Arc::new(CacheState::default())),
|
state: ArcSwap::new(Arc::new(CacheState::default())),
|
||||||
write_lock: Mutex::new(()),
|
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>>;
|
pub(crate) type CacheSnapshot = Guard<Arc<CacheState>>;
|
||||||
|
|
||||||
impl Cache {
|
impl Cache {
|
||||||
|
pub(crate) fn service_account_mutation_lock(&self) -> &AsyncMutex<()> {
|
||||||
|
&self.service_account_mutation_lock
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn snapshot(&self) -> CacheSnapshot {
|
pub(crate) fn snapshot(&self) -> CacheSnapshot {
|
||||||
self.state.load()
|
self.state.load()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub use binding::FederatedSessionBinding;
|
|||||||
pub use error::{FederatedSessionBindingError, FederationError, Result};
|
pub use error::{FederatedSessionBindingError, FederationError, Result};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedLoginSession, FederatedSession,
|
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedLoginSession, FederatedSession,
|
||||||
FederatedSessionTransaction,
|
FederatedSessionTransaction, OIDC_VIRTUAL_PARENT_CLAIM,
|
||||||
};
|
};
|
||||||
pub use provider::FederatedIdentityProvider;
|
pub use provider::FederatedIdentityProvider;
|
||||||
pub use registry::FederatedIdentityRegistry;
|
pub use registry::FederatedIdentityRegistry;
|
||||||
|
|||||||
@@ -13,9 +13,13 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use rustfs_credentials::Credentials;
|
use rustfs_credentials::Credentials;
|
||||||
|
use rustfs_utils::HashAlgorithm;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FederatedClaims {
|
pub struct FederatedClaims {
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
@@ -53,6 +57,27 @@ impl FederatedAuthorization {
|
|||||||
pub fn has_authorization_context(&self) -> bool {
|
pub fn has_authorization_context(&self) -> bool {
|
||||||
!self.policies.is_empty() || !self.groups.is_empty()
|
!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)]
|
#[derive(Debug)]
|
||||||
@@ -99,7 +124,10 @@ mod tests {
|
|||||||
fn authorization(policies: Vec<String>, groups: Vec<String>) -> FederatedAuthorization {
|
fn authorization(policies: Vec<String>, groups: Vec<String>) -> FederatedAuthorization {
|
||||||
FederatedAuthorization {
|
FederatedAuthorization {
|
||||||
provider_id: "standard_oidc".to_string(),
|
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,
|
policies,
|
||||||
groups,
|
groups,
|
||||||
roles_claim_key: None,
|
roles_claim_key: None,
|
||||||
@@ -121,4 +149,61 @@ mod tests {
|
|||||||
assert!(authorization(vec!["consoleAdmin".to_string()], Vec::new()).has_authorization_context());
|
assert!(authorization(vec!["consoleAdmin".to_string()], Vec::new()).has_authorization_context());
|
||||||
assert!(authorization(Vec::new(), vec!["RustFS.ConsoleAdmin".to_string()]).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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
pub(crate) async fn notify_iam_load_group(group: &str) -> Vec<IamNotificationPeerErr> {
|
||||||
match runtime_sources::notification_sys() {
|
match runtime_sources::notification_sys() {
|
||||||
Some(notification_sys) => notification_sys.load_group(group).await.into_iter().map(Into::into).collect(),
|
Some(notification_sys) => notification_sys.load_group(group).await.into_iter().map(Into::into).collect(),
|
||||||
|
|||||||
+226
-11
@@ -782,6 +782,7 @@ where
|
|||||||
return Err(Error::InvalidArgument);
|
return Err(Error::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _mutation_guard = self.cache.service_account_mutation_lock().lock().await;
|
||||||
let cache = self.cache.snapshot();
|
let cache = self.cache.snapshot();
|
||||||
if cache.users.contains_key(&cred.access_key) || cache.sts_accounts.contains_key(&cred.access_key) {
|
if cache.users.contains_key(&cred.access_key) || cache.sts_accounts.contains_key(&cred.access_key) {
|
||||||
return Err(Error::AccessKeyAlreadyExists);
|
return Err(Error::AccessKeyAlreadyExists);
|
||||||
@@ -800,6 +801,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_service_account(&self, name: &str, opts: UpdateServiceAccountOpts) -> Result<OffsetDateTime> {
|
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 cache = self.cache.snapshot();
|
||||||
let Some(ui) = cache.users.get(name).cloned() else {
|
let Some(ui) = cache.users.get(name).cloned() else {
|
||||||
return Err(Error::NoSuchServiceAccount(name.to_string()));
|
return Err(Error::NoSuchServiceAccount(name.to_string()));
|
||||||
@@ -891,12 +893,13 @@ where
|
|||||||
cr.session_token = jwt_sign(&m, &cr.secret_key)?;
|
cr.session_token = jwt_sign(&m, &cr.secret_key)?;
|
||||||
|
|
||||||
let u = UserIdentity::new(cr);
|
let u = UserIdentity::new(cr);
|
||||||
|
let updated_at = u.update_at.unwrap_or_else(OffsetDateTime::now_utc);
|
||||||
self.api
|
self.api
|
||||||
.save_user_identity(&u.credentials.access_key, UserType::Svc, u.clone(), None)
|
.save_user_identity(&u.credentials.access_key, UserType::Svc, u.clone(), None)
|
||||||
.await?;
|
.await?;
|
||||||
self.update_user_with_claims(&u.credentials.access_key, u.clone())?;
|
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>> {
|
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<()> {
|
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() {
|
if access_key.is_empty() {
|
||||||
return Err(Error::InvalidArgument);
|
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 {
|
if utype == UserType::Reg {
|
||||||
let cache = self.cache.snapshot();
|
let cache = self.cache.snapshot();
|
||||||
let member_of = cache.user_group_memberships.get(access_key).cloned();
|
let member_of = cache.user_group_memberships.get(access_key).cloned();
|
||||||
@@ -1429,15 +1447,15 @@ where
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deleted_at = OffsetDateTime::now_utc();
|
||||||
self.cache.with_write_lock(|cache| {
|
self.cache.with_write_lock(|cache| {
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
if utype == UserType::Sts {
|
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<()> {
|
pub async fn update_user_secret_key(&self, access_key: &str, secret_key: &str) -> Result<()> {
|
||||||
@@ -1958,6 +1976,11 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub async fn user_notification_handler(&self, name: &str, user_type: UserType) -> Result<()> {
|
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();
|
let mut m = HashMap::new();
|
||||||
if let Err(err) = self.api.load_user_no_lock(name, user_type, &mut m).await {
|
if let Err(err) = self.api.load_user_no_lock(name, user_type, &mut m).await {
|
||||||
if !is_err_no_such_user(&err) {
|
if !is_err_no_such_user(&err) {
|
||||||
@@ -2222,9 +2245,10 @@ mod tests {
|
|||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{
|
sync::{
|
||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
atomic::{AtomicUsize, Ordering},
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct FailingInitialLoadStore;
|
struct FailingInitialLoadStore;
|
||||||
@@ -2357,6 +2381,12 @@ mod tests {
|
|||||||
saved_user: Arc<Mutex<Option<UserIdentity>>>,
|
saved_user: Arc<Mutex<Option<UserIdentity>>>,
|
||||||
load_attempts: Arc<AtomicUsize>,
|
load_attempts: Arc<AtomicUsize>,
|
||||||
visible_after_attempt: usize,
|
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 {
|
impl DelayedTempUserVisibilityStore {
|
||||||
@@ -2365,6 +2395,12 @@ mod tests {
|
|||||||
saved_user: Arc::new(Mutex::new(None)),
|
saved_user: Arc::new(Mutex::new(None)),
|
||||||
load_attempts: Arc::new(AtomicUsize::new(0)),
|
load_attempts: Arc::new(AtomicUsize::new(0)),
|
||||||
visible_after_attempt,
|
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(
|
async fn save_user_identity(
|
||||||
&self,
|
&self,
|
||||||
_name: &str,
|
_name: &str,
|
||||||
_user_type: UserType,
|
user_type: UserType,
|
||||||
item: UserIdentity,
|
item: UserIdentity,
|
||||||
_ttl: Option<usize>,
|
_ttl: Option<usize>,
|
||||||
) -> Result<()> {
|
) -> 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);
|
*self.saved_user.lock().expect("saved_user mutex poisoned") = Some(item);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_user_identity(&self, _name: &str, _user_type: UserType) -> Result<()> {
|
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> {
|
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()))
|
.ok_or_else(|| Error::NoSuchUser(name.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_user(&self, _name: &str, _user_type: UserType, _m: &mut HashMap<String, UserIdentity>) -> Result<()> {
|
async fn load_user(&self, name: &str, user_type: UserType, m: &mut HashMap<String, UserIdentity>) -> Result<()> {
|
||||||
Err(Error::InvalidArgument)
|
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<()> {
|
async fn load_users(&self, _user_type: UserType, _m: &mut HashMap<String, UserIdentity>) -> Result<()> {
|
||||||
@@ -2485,7 +2537,7 @@ mod tests {
|
|||||||
_is_group: bool,
|
_is_group: bool,
|
||||||
_m: &mut HashMap<String, MappedPolicy>,
|
_m: &mut HashMap<String, MappedPolicy>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Err(Error::InvalidArgument)
|
Err(Error::NoSuchPolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_mapped_policies(
|
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]
|
#[tokio::test]
|
||||||
async fn test_init_keeps_error_state_when_initial_load_fails() {
|
async fn test_init_keeps_error_state_when_initial_load_fails() {
|
||||||
let (sender, receiver) = mpsc::channel::<i64>(1);
|
let (sender, receiver) = mpsc::channel::<i64>(1);
|
||||||
|
|||||||
+834
-28
File diff suppressed because it is too large
Load Diff
@@ -298,6 +298,11 @@ pub struct SRSvcAccDelete {
|
|||||||
pub api_version: Option<String>,
|
pub api_version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SRSvcAccReplicationEnvelope {
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct SRSvcAccChange {
|
pub struct SRSvcAccChange {
|
||||||
#[serde(rename = "crSvcAccCreate", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "crSvcAccCreate", skip_serializing_if = "Option::is_none")]
|
||||||
@@ -306,6 +311,8 @@ pub struct SRSvcAccChange {
|
|||||||
pub update: Option<SRSvcAccUpdate>,
|
pub update: Option<SRSvcAccUpdate>,
|
||||||
#[serde(rename = "crSvcAccDelete", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "crSvcAccDelete", skip_serializing_if = "Option::is_none")]
|
||||||
pub delete: Option<SRSvcAccDelete>,
|
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")]
|
#[serde(rename = "apiVersion", skip_serializing_if = "Option::is_none")]
|
||||||
pub api_version: Option<String>,
|
pub api_version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ metrics-util = { version = "0.20", features = ["debugging"] }
|
|||||||
opentelemetry_sdk = { workspace = true, features = ["rt-tokio"] }
|
opentelemetry_sdk = { workspace = true, features = ["rt-tokio"] }
|
||||||
rsa = { workspace = true }
|
rsa = { workspace = true }
|
||||||
rcgen = { workspace = true }
|
rcgen = { workspace = true }
|
||||||
|
rustfs-test-utils.workspace = true
|
||||||
# diagnose_e2e fixtures (archives are generated in-test, never checked in)
|
# diagnose_e2e fixtures (archives are generated in-test, never checked in)
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
zstd = { workspace = true }
|
zstd = { workspace = true }
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
use super::iam_error::iam_error_to_s3_error;
|
use super::iam_error::iam_error_to_s3_error;
|
||||||
use crate::admin::access_key_identity;
|
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::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::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};
|
use crate::auth::{constant_time_eq, get_condition_values, get_session_token};
|
||||||
@@ -38,7 +38,7 @@ use rustfs_madmin::{
|
|||||||
ServiceAccountInfo, TemporaryAccountInfoResp, UpdateServiceAccountReq,
|
ServiceAccountInfo, TemporaryAccountInfoResp, UpdateServiceAccountReq,
|
||||||
};
|
};
|
||||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
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::S3ErrorCode::InvalidRequest;
|
||||||
use s3s::header::CONTENT_LENGTH;
|
use s3s::header::CONTENT_LENGTH;
|
||||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, header::CONTENT_TYPE, s3_error};
|
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_COMPONENT_ADMIN: &str = "admin";
|
||||||
const LOG_SUBSYSTEM_SERVICE_ACCOUNT: &str = "service_account";
|
const LOG_SUBSYSTEM_SERVICE_ACCOUNT: &str = "service_account";
|
||||||
const EVENT_ADMIN_SERVICE_ACCOUNT_STATE: &str = "admin_service_account_state";
|
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> {
|
fn sr_session_policy_from_policy(policy: Option<&Policy>) -> S3Result<SRSessionPolicy> {
|
||||||
let Some(value) = value else {
|
let Some(policy) = policy else {
|
||||||
return Ok(SRSessionPolicy::default());
|
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))
|
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 {
|
fn compat_time_sentinel() -> OffsetDateTime {
|
||||||
OffsetDateTime::UNIX_EPOCH
|
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 {
|
fn is_service_account_owner_of(caller: &StoredCredentials, target_parent_user: &str) -> bool {
|
||||||
let caller_parent = if caller.parent_user.is_empty() {
|
let caller_parent = if caller.parent_user.is_empty() {
|
||||||
caller.access_key.as_str()
|
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)?))
|
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<()> {
|
pub fn register_service_account_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||||
r.insert(
|
r.insert(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
@@ -293,11 +324,12 @@ impl Operation for AddServiceAccount {
|
|||||||
|
|
||||||
create_req.validate().map_err(|e| S3Error::with_message(InvalidRequest, e))?;
|
create_req.validate().map_err(|e| S3Error::with_message(InvalidRequest, e))?;
|
||||||
|
|
||||||
let session_policy = if let Some(policy) = &create_req.policy {
|
let session_policy = parse_new_service_account_policy(create_req.policy.as_ref())?;
|
||||||
Some(parse_service_account_policy(policy)?)
|
let replication_policy = session_policy
|
||||||
} else {
|
.as_ref()
|
||||||
None
|
.map(serde_json::to_string)
|
||||||
};
|
.transpose()
|
||||||
|
.map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))?;
|
||||||
|
|
||||||
let Some(sys_cred) = current_action_credentials() else {
|
let Some(sys_cred) = current_action_credentials() else {
|
||||||
return Err(s3_error!(InvalidRequest, "get sys cred failed"));
|
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;
|
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
|
// 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
|
// a root-parented (owner) service account. Evaluated on the original
|
||||||
// `target_user` the caller submitted, before the derived-credential rewrite
|
// `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 target_groups = None;
|
||||||
let mut opts = NewServiceAccountOpts {
|
let opts = NewServiceAccountOpts {
|
||||||
access_key: create_req.access_key,
|
access_key: create_req.access_key,
|
||||||
secret_key: create_req.secret_key,
|
secret_key: create_req.secret_key,
|
||||||
name: create_req.name,
|
name: create_req.name,
|
||||||
@@ -389,48 +420,44 @@ impl Operation for AddServiceAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target_groups = req_groups;
|
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_groups = target_groups.clone().unwrap_or_default();
|
||||||
let replication_name = opts.name.clone().unwrap_or_default();
|
let replication_name = opts.name.clone().unwrap_or_default();
|
||||||
let replication_description = opts.description.clone().unwrap_or_default();
|
let replication_description = opts.description.clone().unwrap_or_default();
|
||||||
let replication_expiration = opts.expiration;
|
let replication_expiration = opts.expiration;
|
||||||
|
|
||||||
let (new_cred, _) = iam_store
|
let create_result = if is_svc_acc {
|
||||||
.new_service_account(&target_user, target_groups, opts)
|
iam_store
|
||||||
.await
|
.new_service_account_from_caller(&target_user, target_groups, opts, &cred)
|
||||||
.map_err(|e| {
|
.await
|
||||||
debug!(
|
} else {
|
||||||
component = LOG_COMPONENT_ADMIN,
|
let replication_claims = opts.claims.clone().unwrap_or_default();
|
||||||
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
iam_store
|
||||||
event = EVENT_ADMIN_SERVICE_ACCOUNT_STATE,
|
.new_service_account(&target_user, target_groups, opts)
|
||||||
target_user = %target_user,
|
.await
|
||||||
result = "create_failed",
|
.map(|(credentials, updated_at)| (credentials, updated_at, replication_claims))
|
||||||
error = ?e,
|
};
|
||||||
"admin service account state"
|
let (new_cred, updated_at, replication_claims) = create_result.map_err(|e| {
|
||||||
);
|
debug!(
|
||||||
match e {
|
component = LOG_COMPONENT_ADMIN,
|
||||||
rustfs_iam::error::Error::InvalidAccessKeyLength
|
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
||||||
| rustfs_iam::error::Error::InvalidSecretKeyLength
|
event = EVENT_ADMIN_SERVICE_ACCOUNT_STATE,
|
||||||
| rustfs_iam::error::Error::AccessKeyAlreadyExists => iam_error_to_s3_error(e),
|
target_user = %target_user,
|
||||||
err => s3_error!(InternalError, "create service account failed, e: {:?}", err),
|
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 {
|
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
||||||
r#type: "service-account".to_string(),
|
r#type: "service-account".to_string(),
|
||||||
@@ -441,22 +468,18 @@ impl Operation for AddServiceAccount {
|
|||||||
secret_key: new_cred.secret_key.clone(),
|
secret_key: new_cred.secret_key.clone(),
|
||||||
groups: replication_groups,
|
groups: replication_groups,
|
||||||
claims: replication_claims,
|
claims: replication_claims,
|
||||||
session_policy: replication_policy
|
session_policy: replication_session_policy,
|
||||||
.as_deref()
|
|
||||||
.map(SRSessionPolicy::from_json)
|
|
||||||
.transpose()
|
|
||||||
.map_err(|e| s3_error!(InvalidArgument, "marshal policy failed: {:?}", e))?
|
|
||||||
.unwrap_or_default(),
|
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
name: replication_name,
|
name: replication_name,
|
||||||
description: replication_description,
|
description: replication_description,
|
||||||
expiration: replication_expiration,
|
expiration: replication_expiration,
|
||||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
}),
|
}),
|
||||||
|
oidc_service_account_envelope,
|
||||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
updated_at: Some(OffsetDateTime::now_utc()),
|
updated_at: Some(updated_at),
|
||||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
@@ -620,6 +643,7 @@ impl Operation for UpdateServiceAccount {
|
|||||||
let new_policy = update_req.new_policy.clone();
|
let new_policy = update_req.new_policy.clone();
|
||||||
|
|
||||||
let sp = parse_update_service_account_policy(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 {
|
let opts = UpdateServiceAccountOpts {
|
||||||
secret_key: new_secret_key.clone(),
|
secret_key: new_secret_key.clone(),
|
||||||
@@ -638,20 +662,7 @@ impl Operation for UpdateServiceAccount {
|
|||||||
|
|
||||||
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
||||||
r#type: "service-account".to_string(),
|
r#type: "service-account".to_string(),
|
||||||
svc_acc_change: Some(SRSvcAccChange {
|
svc_acc_change: Some(svc_acc_change),
|
||||||
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()
|
|
||||||
}),
|
|
||||||
updated_at: Some(updated_at),
|
updated_at: Some(updated_at),
|
||||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1352,33 +1363,37 @@ impl Operation for DeleteServiceAccount {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
iam_store.delete_service_account(&query.access_key, true).await.map_err(|e| {
|
let deleted_at = iam_store
|
||||||
debug!(
|
.delete_service_account_with_revision(&query.access_key, true)
|
||||||
component = LOG_COMPONENT_ADMIN,
|
.await
|
||||||
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
.map_err(|e| {
|
||||||
event = "service_account_delete_failed",
|
debug!(
|
||||||
access_key = %query.access_key,
|
component = LOG_COMPONENT_ADMIN,
|
||||||
error = ?e,
|
subsystem = LOG_SUBSYSTEM_SERVICE_ACCOUNT,
|
||||||
"Failed to delete service account"
|
event = "service_account_delete_failed",
|
||||||
);
|
access_key = %query.access_key,
|
||||||
s3_error!(InternalError, "delete service account failed")
|
error = ?e,
|
||||||
})?;
|
"Failed to delete service account"
|
||||||
|
);
|
||||||
|
s3_error!(InternalError, "delete service account failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
if let Some(deleted_at) = deleted_at
|
||||||
r#type: "service-account".to_string(),
|
&& let Err(err) = site_replication_iam_change_hook(SRIAMItem {
|
||||||
svc_acc_change: Some(SRSvcAccChange {
|
r#type: "service-account".to_string(),
|
||||||
delete: Some(SRSvcAccDelete {
|
svc_acc_change: Some(SRSvcAccChange {
|
||||||
access_key: query.access_key.clone(),
|
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()),
|
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()),
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
})
|
||||||
updated_at: Some(OffsetDateTime::now_utc()),
|
.await
|
||||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
component = LOG_COMPONENT_ADMIN,
|
component = LOG_COMPONENT_ADMIN,
|
||||||
@@ -1713,6 +1728,81 @@ mod tests {
|
|||||||
assert!(policy.statements.is_empty());
|
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]
|
#[test]
|
||||||
fn parse_service_account_policy_reports_missing_resource() {
|
fn parse_service_account_policy_reports_missing_resource() {
|
||||||
let err = parse_service_account_policy(&json!({
|
let err = parse_service_account_policy(&json!({
|
||||||
@@ -1774,22 +1864,6 @@ mod tests {
|
|||||||
assert!(!is_service_account_owner_of(&foreign_user, "owner-user"));
|
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]
|
#[test]
|
||||||
fn fallback_access_key_info_allows_same_regular_user() {
|
fn fallback_access_key_info_allows_same_regular_user() {
|
||||||
let requester = StoredCredentials {
|
let requester = StoredCredentials {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ use rustfs_config::{
|
|||||||
MAX_ADMIN_REQUEST_BODY_SIZE,
|
MAX_ADMIN_REQUEST_BODY_SIZE,
|
||||||
};
|
};
|
||||||
use rustfs_iam::error::is_err_no_such_service_account;
|
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::store::{MappedPolicy, UserType};
|
||||||
use rustfs_iam::sys::{
|
use rustfs_iam::sys::{
|
||||||
NewServiceAccountOpts, SITE_REPLICATOR_SERVICE_ACCOUNT, UpdateServiceAccountOpts, get_claims_from_token_with_secret,
|
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,
|
ReplicateEditStatus, ReplicateRemoveStatus, ResyncBucketStatus, SITE_REPL_API_VERSION, SRBucketInfo, SRBucketMeta,
|
||||||
SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, SRIAMPolicy, SRILMExpiryStatsSummary, SRInfo, SRMetric,
|
SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, SRIAMPolicy, SRILMExpiryStatsSummary, SRInfo, SRMetric,
|
||||||
SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation, SRPolicyMapping, SRPolicyStatsSummary, SRRemoveReq,
|
SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation, SRPolicyMapping, SRPolicyStatsSummary, SRRemoveReq,
|
||||||
SRResyncOpStatus, SRRetryStats, SRSiteSummary, SRStateEditReq, SRStateInfo, SRStatusInfo, SRUserStatsSummary,
|
SRResyncOpStatus, SRRetryStats, SRSessionPolicy, SRSiteSummary, SRStateEditReq, SRStateInfo, SRStatusInfo, SRSvcAccCreate,
|
||||||
SiteReplicationInfo, SyncStatus, WorkerStat,
|
SRUserStatsSummary, SiteReplicationInfo, SyncStatus, WorkerStat,
|
||||||
};
|
};
|
||||||
use rustfs_policy::policy::{
|
use rustfs_policy::policy::{
|
||||||
Policy,
|
Policy,
|
||||||
@@ -106,7 +107,7 @@ use uuid::Uuid;
|
|||||||
const LOG_COMPONENT_ADMIN: &str = "admin";
|
const LOG_COMPONENT_ADMIN: &str = "admin";
|
||||||
const LOG_SUBSYSTEM_SITE_REPLICATION: &str = "site_replication";
|
const LOG_SUBSYSTEM_SITE_REPLICATION: &str = "site_replication";
|
||||||
const EVENT_ADMIN_SITE_REPLICATION_STATE: &str = "admin_site_replication_state";
|
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_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_ADD_SUCCESS: &str = "Requested sites were configured for replication successfully.";
|
||||||
const SITE_REPL_EDIT_SUCCESS: &str = "Requested site was updated 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
|
!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<()> {
|
async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
||||||
let Some(iam_sys) = current_iam_handle() else {
|
let Some(iam_sys) = current_iam_handle() else {
|
||||||
return Err(s3_error!(InvalidRequest, "iam not init"));
|
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)
|
.map(OffsetDateTime::from_unix_timestamp)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|e| s3_error!(InvalidRequest, "invalid STS expiry: {e}"))?;
|
.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 {
|
let cred = rustfs_credentials::Credentials {
|
||||||
access_key: sts_credential.access_key.clone(),
|
access_key: sts_credential.access_key.clone(),
|
||||||
secret_key: sts_credential.secret_key.clone(),
|
secret_key: sts_credential.secret_key.clone(),
|
||||||
@@ -6346,15 +6446,12 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
|||||||
expiration,
|
expiration,
|
||||||
status: "on".to_string(),
|
status: "on".to_string(),
|
||||||
parent_user: sts_credential.parent_user.clone(),
|
parent_user: sts_credential.parent_user.clone(),
|
||||||
|
groups,
|
||||||
claims: Some(claims),
|
claims: Some(claims),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
iam_sys
|
iam_sys
|
||||||
.set_temp_user(
|
.set_temp_user(&sts_credential.access_key, &cred, compatibility_policy)
|
||||||
&sts_credential.access_key,
|
|
||||||
&cred,
|
|
||||||
(!sts_credential.parent_policy_mapping.is_empty()).then_some(sts_credential.parent_policy_mapping.as_str()),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -6393,16 +6490,31 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
|||||||
let Some(change) = item.svc_acc_change else {
|
let Some(change) = item.svc_acc_change else {
|
||||||
return Err(s3_error!(InvalidRequest, "serviceAccountChange is required"));
|
return Err(s3_error!(InvalidRequest, "serviceAccountChange is required"));
|
||||||
};
|
};
|
||||||
|
let envelope = change.oidc_service_account_envelope;
|
||||||
if let Some(create) = change.create {
|
if let Some(create) = change.create {
|
||||||
if let Some(local) = iam_sys.get_user(&create.access_key).await
|
let local_updated_at = iam_sys
|
||||||
&& is_stale_update(local.update_at.unwrap_or(OffsetDateTime::UNIX_EPOCH), incoming_updated_at)
|
.get_user(&create.access_key)
|
||||||
{
|
.await
|
||||||
return Ok(());
|
.map(|local| local.update_at.unwrap_or(OffsetDateTime::UNIX_EPOCH));
|
||||||
}
|
let replicated_policy = if create.access_key == SITE_REPLICATOR_SERVICE_ACCOUNT {
|
||||||
let session_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)) {
|
||||||
Some(site_replicator_service_account_policy()?)
|
return Ok(());
|
||||||
|
}
|
||||||
|
ReplicatedServiceAccountPolicy {
|
||||||
|
policy: Some(site_replicator_service_account_policy()?),
|
||||||
|
is_envelope: false,
|
||||||
|
}
|
||||||
} else {
|
} 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 {
|
match iam_sys.get_service_account(&create.access_key).await {
|
||||||
Ok((existing, _)) => {
|
Ok((existing, _)) => {
|
||||||
@@ -6417,10 +6529,10 @@ async fn apply_iam_item(item: SRIAMItem) -> S3Result<()> {
|
|||||||
.update_service_account(
|
.update_service_account(
|
||||||
&create.access_key,
|
&create.access_key,
|
||||||
UpdateServiceAccountOpts {
|
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),
|
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,
|
expiration: create.expiration,
|
||||||
status: (!create.status.is_empty()).then_some(create.status),
|
status: (!create.status.is_empty()).then_some(create.status),
|
||||||
allow_site_replicator_account: create.access_key == SITE_REPLICATOR_SERVICE_ACCOUNT,
|
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,
|
&create.parent,
|
||||||
Some(create.groups),
|
Some(create.groups),
|
||||||
NewServiceAccountOpts {
|
NewServiceAccountOpts {
|
||||||
session_policy,
|
session_policy: replicated_policy.policy,
|
||||||
access_key: create.access_key,
|
access_key: create.access_key,
|
||||||
secret_key: create.secret_key,
|
secret_key: create.secret_key,
|
||||||
name: (!create.name.is_empty()).then_some(create.name),
|
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 {}
|
pub struct SiteReplicationAddHandler {}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -7923,6 +8050,278 @@ mod tests {
|
|||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpListener;
|
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 {
|
fn valid_test_ca_pem(name: &str) -> String {
|
||||||
rcgen::generate_simple_self_signed(vec![name.to_string()])
|
rcgen::generate_simple_self_signed(vec![name.to_string()])
|
||||||
.expect("generate test CA")
|
.expect("generate test CA")
|
||||||
|
|||||||
@@ -1078,7 +1078,7 @@ impl Operation for ImportIam {
|
|||||||
if let Some(file_content) = file_content {
|
if let Some(file_content) = file_content {
|
||||||
let svc_accts: HashMap<String, SRSvcAccCreate> = serde_json::from_slice(&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()))?;
|
.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) {
|
if skipped.service_accounts.contains(&ak) {
|
||||||
continue;
|
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 {
|
let opts = NewServiceAccountOpts {
|
||||||
session_policy: sp,
|
session_policy: sp,
|
||||||
access_key: ak.clone(),
|
access_key: ak.clone(),
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ use crate::admin::{
|
|||||||
runtime_sources::{current_action_credentials, current_ready_iam_handle, current_token_signing_key},
|
runtime_sources::{current_action_credentials, current_ready_iam_handle, current_token_signing_key},
|
||||||
};
|
};
|
||||||
use rustfs_iam::{
|
use rustfs_iam::{
|
||||||
federation::{FederatedSessionBinding, FederatedSessionBindingError, FederatedSessionTransaction},
|
federation::{FederatedSessionBinding, FederatedSessionBindingError, FederatedSessionTransaction, OIDC_VIRTUAL_PARENT_CLAIM},
|
||||||
store::object::ObjectStore,
|
store::{MappedPolicy, object::ObjectStore},
|
||||||
sys::IamSys,
|
sys::{IamSys, is_safe_claim_policy_name},
|
||||||
};
|
};
|
||||||
use rustfs_madmin::{SITE_REPL_API_VERSION, SRIAMItem, SRSTSCredential};
|
use rustfs_madmin::{SITE_REPL_API_VERSION, SRIAMItem, SRSTSCredential};
|
||||||
use rustfs_policy::auth::get_new_credentials_with_metadata;
|
use rustfs_policy::auth::get_new_credentials_with_metadata;
|
||||||
@@ -32,6 +32,17 @@ use tracing::{debug, warn};
|
|||||||
|
|
||||||
pub(crate) struct DefaultFederatedSessionBinding;
|
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> {
|
fn build_oidc_token_claims(transaction: &FederatedSessionTransaction) -> HashMap<String, Value> {
|
||||||
let authorization = &transaction.authorization;
|
let authorization = &transaction.authorization;
|
||||||
let claims = &authorization.claims;
|
let claims = &authorization.claims;
|
||||||
@@ -168,22 +179,23 @@ fn parent_user_is_reserved(parent_user: &str, root_access_key: Option<&str>) ->
|
|||||||
|
|
||||||
fn issue_credentials(
|
fn issue_credentials(
|
||||||
transaction: &FederatedSessionTransaction,
|
transaction: &FederatedSessionTransaction,
|
||||||
|
selected_policy_names: &[String],
|
||||||
secret: Option<&str>,
|
secret: Option<&str>,
|
||||||
) -> Result<rustfs_credentials::Credentials, FederatedSessionBindingError> {
|
) -> Result<rustfs_credentials::Credentials, FederatedSessionBindingError> {
|
||||||
let authorization = &transaction.authorization;
|
let authorization = &transaction.authorization;
|
||||||
let claims = &authorization.claims;
|
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 mut token_claims = build_oidc_token_claims(transaction);
|
||||||
let duration = i64::try_from(transaction.duration_seconds)
|
let duration = i64::try_from(transaction.duration_seconds)
|
||||||
.map_err(|_| FederatedSessionBindingError::InvalidRequest("invalid duration".to_string()))?;
|
.map_err(|_| FederatedSessionBindingError::InvalidRequest("invalid duration".to_string()))?;
|
||||||
let exp = OffsetDateTime::now_utc().saturating_add(Duration::seconds(duration));
|
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())));
|
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
|
// 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
|
// request time `parent_user == root access key` is treated as owner, so issuing such a
|
||||||
// rustfs_iam owner resolution), so issuing such a credential would silently grant a
|
// credential would silently grant a federated identity full owner access.
|
||||||
// federated identity full owner access purely because its display name matched root.
|
|
||||||
// Deny before any credential generation, `set_temp_user`, or site replication.
|
|
||||||
let root_access_key = current_action_credentials().map(|cred| cred.access_key);
|
let root_access_key = current_action_credentials().map(|cred| cred.access_key);
|
||||||
if parent_user_is_reserved(&parent_user, root_access_key.as_deref()) {
|
if parent_user_is_reserved(&parent_user, root_access_key.as_deref()) {
|
||||||
return Err(FederatedSessionBindingError::InvalidRequest(
|
return Err(FederatedSessionBindingError::InvalidRequest(
|
||||||
@@ -205,9 +217,12 @@ fn issue_credentials(
|
|||||||
"OIDC STS credential claims prepared"
|
"OIDC STS credential claims prepared"
|
||||||
);
|
);
|
||||||
token_claims.insert("parent".to_string(), Value::String(parent_user.clone()));
|
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() {
|
if !selected_policy_names.is_empty() {
|
||||||
token_claims.insert("policy".to_string(), Value::String(authorization.policies.join(",")));
|
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() {
|
if let Some(policy) = transaction.session_policy.as_deref() {
|
||||||
populate_session_policy(&mut token_claims, policy).map_err(binding_error_from_s3)?;
|
populate_session_policy(&mut token_claims, policy).map_err(binding_error_from_s3)?;
|
||||||
@@ -221,11 +236,7 @@ fn issue_credentials(
|
|||||||
Ok(credentials)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn site_replication_item(
|
fn site_replication_item(credentials: &rustfs_credentials::Credentials, updated_at: OffsetDateTime) -> SRIAMItem {
|
||||||
credentials: &rustfs_credentials::Credentials,
|
|
||||||
transaction: &FederatedSessionTransaction,
|
|
||||||
updated_at: OffsetDateTime,
|
|
||||||
) -> SRIAMItem {
|
|
||||||
SRIAMItem {
|
SRIAMItem {
|
||||||
r#type: "sts-credential".to_string(),
|
r#type: "sts-credential".to_string(),
|
||||||
sts_credential: Some(SRSTSCredential {
|
sts_credential: Some(SRSTSCredential {
|
||||||
@@ -233,7 +244,7 @@ fn site_replication_item(
|
|||||||
secret_key: credentials.secret_key.clone(),
|
secret_key: credentials.secret_key.clone(),
|
||||||
session_token: credentials.session_token.clone(),
|
session_token: credentials.session_token.clone(),
|
||||||
parent_user: credentials.parent_user.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()),
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
}),
|
}),
|
||||||
updated_at: Some(updated_at),
|
updated_at: Some(updated_at),
|
||||||
@@ -249,17 +260,35 @@ impl FederatedSessionBinding for DefaultFederatedSessionBinding {
|
|||||||
transaction: &FederatedSessionTransaction,
|
transaction: &FederatedSessionTransaction,
|
||||||
) -> Result<rustfs_credentials::Credentials, FederatedSessionBindingError> {
|
) -> Result<rustfs_credentials::Credentials, FederatedSessionBindingError> {
|
||||||
let authorization = &transaction.authorization;
|
let authorization = &transaction.authorization;
|
||||||
let secret = current_token_signing_key();
|
|
||||||
let credentials = issue_credentials(transaction, secret.as_deref())?;
|
|
||||||
|
|
||||||
let iam_store =
|
let iam_store =
|
||||||
current_ready_iam_handle().map_err(|_| FederatedSessionBindingError::Internal("IAM not initialized".to_string()))?;
|
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) {
|
if tracing::enabled!(tracing::Level::DEBUG) {
|
||||||
log_oidc_policy_diagnostics(
|
log_oidc_policy_diagnostics(
|
||||||
&iam_store,
|
&iam_store,
|
||||||
&authorization.provider_id,
|
&authorization.provider_id,
|
||||||
&credentials.parent_user,
|
&credentials.parent_user,
|
||||||
&authorization.policies,
|
&selected_policy_names,
|
||||||
&authorization.groups,
|
&authorization.groups,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -270,7 +299,7 @@ impl FederatedSessionBinding for DefaultFederatedSessionBinding {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| FederatedSessionBindingError::Internal("failed to store temp user".to_string()))?;
|
.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}");
|
warn!("site replication OIDC STS hook failed, err: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +321,7 @@ mod tests {
|
|||||||
email: "user@example.com".to_string(),
|
email: "user@example.com".to_string(),
|
||||||
username: "user".to_string(),
|
username: "user".to_string(),
|
||||||
groups: vec!["source-group".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()],
|
policies: vec!["readwrite".to_string()],
|
||||||
groups: vec!["devs".to_string()],
|
groups: vec!["devs".to_string()],
|
||||||
@@ -322,23 +351,32 @@ mod tests {
|
|||||||
fn issued_credentials_and_replication_item_preserve_existing_shape() {
|
fn issued_credentials_and_replication_item_preserve_existing_shape() {
|
||||||
let transaction = transaction();
|
let transaction = transaction();
|
||||||
let secret = "federated-session-test-signing-secret";
|
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");
|
let credentials =
|
||||||
assert_eq!(credentials.parent_user, "user");
|
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()]));
|
assert_eq!(credentials.groups, Some(vec!["devs".to_string()]));
|
||||||
|
|
||||||
let claims = rustfs_iam::sys::get_claims_from_token_with_secret(&credentials.session_token, secret)
|
let claims = rustfs_iam::sys::get_claims_from_token_with_secret(&credentials.session_token, secret)
|
||||||
.expect("issued session token should verify");
|
.expect("issued session token should verify");
|
||||||
assert_eq!(claims.get("iss"), Some(&serde_json::json!("rustfs-oidc")));
|
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("oidc_provider"), Some(&serde_json::json!("default")));
|
||||||
assert_eq!(claims.get("parent"), Some(&serde_json::json!("user")));
|
assert_eq!(
|
||||||
assert_eq!(claims.get("policy"), Some(&serde_json::json!("readwrite")));
|
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("groups"), Some(&serde_json::json!(["devs"])));
|
||||||
assert_eq!(claims.get("roles"), Some(&serde_json::json!(["admin", "reader"])));
|
assert_eq!(claims.get("roles"), Some(&serde_json::json!(["admin", "reader"])));
|
||||||
assert!(!claims.contains_key("oidc_issuer"));
|
assert!(!claims.contains_key("oidc_issuer"));
|
||||||
|
|
||||||
let updated_at = OffsetDateTime::UNIX_EPOCH;
|
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.r#type, "sts-credential");
|
||||||
assert_eq!(item.updated_at, Some(updated_at));
|
assert_eq!(item.updated_at, Some(updated_at));
|
||||||
assert_eq!(item.api_version.as_deref(), Some(SITE_REPL_API_VERSION));
|
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.access_key, credentials.access_key);
|
||||||
assert_eq!(replicated.secret_key, credentials.secret_key);
|
assert_eq!(replicated.secret_key, credentials.secret_key);
|
||||||
assert_eq!(replicated.session_token, credentials.session_token);
|
assert_eq!(replicated.session_token, credentials.session_token);
|
||||||
assert_eq!(replicated.parent_user, "user");
|
assert_eq!(replicated.parent_user, "openid=pUmguI1petsjVfDFQppmmR9yqdmWnBAXGJhHV_s9W3I");
|
||||||
assert_eq!(replicated.parent_policy_mapping, "readwrite");
|
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));
|
assert_eq!(replicated.api_version.as_deref(), Some(SITE_REPL_API_VERSION));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +396,8 @@ mod tests {
|
|||||||
let mut transaction = transaction();
|
let mut transaction = transaction();
|
||||||
transaction.session_policy = Some("not-json".to_string());
|
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(_)));
|
assert!(matches!(error, FederatedSessionBindingError::InvalidRequest(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +418,29 @@ mod tests {
|
|||||||
// Reuse the collision decision the issuance path applies: a federated identity whose
|
// 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.
|
// derived parent_user equals the root access key must be denied at issuance.
|
||||||
let transaction = transaction();
|
let transaction = transaction();
|
||||||
let parent_user = transaction.authorization.claims.session_identity();
|
let parent_user = transaction
|
||||||
assert_eq!(parent_user, "user");
|
.authorization
|
||||||
assert!(parent_user_is_reserved(&parent_user, Some("user")));
|
.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")));
|
assert!(!parent_user_is_reserved(&parent_user, Some("root")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_oidc_issuer_fails_closed_before_credential_issuance() {
|
||||||
|
let mut transaction = transaction();
|
||||||
|
transaction.authorization.claims.raw.clear();
|
||||||
|
|
||||||
|
let error = issue_credentials(&transaction, &transaction.authorization.policies, Some("signing-secret"))
|
||||||
|
.expect_err("credential issuance should reject a missing issuer");
|
||||||
|
assert!(matches!(error, FederatedSessionBindingError::InvalidRequest(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oidc_replication_requires_all_selected_policies() {
|
||||||
|
assert!(all_oidc_policies_resolved(&["readonly".to_string()], "readonly"));
|
||||||
|
assert!(!all_oidc_policies_resolved(&["readonly".to_string(), "missing".to_string()], "readonly"));
|
||||||
|
assert!(!all_oidc_policies_resolved(&[], ""));
|
||||||
|
assert!(!all_oidc_policies_resolved(&["team+readonly".to_string()], "team+readonly"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-10
@@ -246,6 +246,14 @@ pub async fn check_key_valid(session_token: &str, access_key: &str) -> S3Result<
|
|||||||
check_key_valid_with_context(session_token, access_key, None).await
|
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
|
/// Validate an access key, resolving the root credentials and IAM system from
|
||||||
/// an explicit application context when one is given (backlog#1052 S6).
|
/// 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 };
|
cred.claims = if !claims.is_empty() { Some(claims) } else { None };
|
||||||
|
|
||||||
let mut owner =
|
let owner = has_root_access(&sys_cred, &cred);
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((cred, owner))
|
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]
|
#[test]
|
||||||
fn test_iam_auth_creation() {
|
fn test_iam_auth_creation() {
|
||||||
let access_key = "test-access-key";
|
let access_key = "test-access-key";
|
||||||
|
|||||||
@@ -1380,13 +1380,20 @@ impl Node for NodeService {
|
|||||||
error_info: Some("access_key name is missing".to_string()),
|
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 {
|
return Ok(Response::new(DeleteServiceAccountResponse {
|
||||||
success: false,
|
success: false,
|
||||||
error_info: Some("errServerNotInitialized".to_string()),
|
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 {
|
if let Err(err) = resp {
|
||||||
return Ok(Response::new(DeleteServiceAccountResponse {
|
return Ok(Response::new(DeleteServiceAccountResponse {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1859,7 +1866,7 @@ mod tests {
|
|||||||
PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
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,
|
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,
|
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,
|
remove_heal_control_replay, scanner_activity_response, stop_rebalance_response,
|
||||||
};
|
};
|
||||||
use crate::storage::rpc::node_service::heal::heal_topology_fingerprint;
|
use crate::storage::rpc::node_service::heal::heal_topology_fingerprint;
|
||||||
@@ -1871,6 +1878,14 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rustfs_heal::heal::{manager::HealManager, storage::HealStorageAPI};
|
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::models::PingBodyBuilder;
|
||||||
use rustfs_protos::proto_gen::node_service::{
|
use rustfs_protos::proto_gen::node_service::{
|
||||||
BackgroundHealStatusRequest, CheckPartsRequest, DeleteBucketMetadataRequest, DeleteBucketRequest, DeletePathsRequest,
|
BackgroundHealStatusRequest, CheckPartsRequest, DeleteBucketMetadataRequest, DeleteBucketRequest, DeletePathsRequest,
|
||||||
@@ -4088,6 +4103,59 @@ mod tests {
|
|||||||
assert!(delete_response.error_info.unwrap().contains("access_key name is missing"));
|
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]
|
#[tokio::test]
|
||||||
async fn test_load_user_empty_access_key() {
|
async fn test_load_user_empty_access_key() {
|
||||||
let service = create_test_node_service();
|
let service = create_test_node_service();
|
||||||
|
|||||||
Reference in New Issue
Block a user