Files
rustfs/crates/iam/src/federation/model.rs
T
GatewayJ 24cf2cdb78 fix(iam): align OIDC parent IDs with MinIO (#5290)
* fix(iam): align OIDC parent IDs with MinIO

* test(iam): cover OIDC STS binding policy lookup

* test(admin): use runtime facade for AppContext setup

* test(ilm): wait for lifecycle backfill before manual failure

* test(ilm): make overlapping admission check concurrent
2026-07-27 07:16:22 +00:00

186 lines
6.1 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_credentials::Credentials;
use rustfs_utils::HashAlgorithm;
use serde_json::Value;
use std::collections::HashMap;
pub const OIDC_VIRTUAL_PARENT_CLAIM: &str = "x-rustfs-internal-oidc-parent";
#[derive(Debug, Clone)]
pub struct FederatedClaims {
pub sub: String,
pub email: String,
pub username: String,
pub groups: Vec<String>,
pub raw: HashMap<String, Value>,
}
impl FederatedClaims {
pub fn session_identity(&self) -> String {
if !self.username.is_empty() {
self.username.clone()
} else if !self.email.is_empty() {
self.email.clone()
} else if !self.sub.is_empty() {
self.sub.clone()
} else {
"oidc-user-unknown".to_string()
}
}
}
#[derive(Debug, Clone)]
pub struct FederatedAuthorization {
pub provider_id: String,
pub claims: FederatedClaims,
pub policies: Vec<String>,
pub groups: Vec<String>,
pub roles_claim_key: Option<String>,
pub roles: Vec<String>,
}
impl FederatedAuthorization {
pub fn has_authorization_context(&self) -> bool {
!self.policies.is_empty() || !self.groups.is_empty()
}
pub fn oidc_virtual_parent(&self) -> Option<String> {
let issuer = self.claims.raw.get("iss")?.as_str()?;
let subject = self.claims.sub.as_str();
if issuer.is_empty() || subject.is_empty() {
return None;
}
let mut source = Vec::with_capacity(8 + subject.len() + issuer.len());
source.extend_from_slice(b"openid:");
source.extend_from_slice(subject.as_bytes());
source.push(b':');
source.extend_from_slice(issuer.as_bytes());
let digest = HashAlgorithm::SHA256.hash_encode(&source);
Some(base64_simd::URL_SAFE_NO_PAD.encode_to_string(digest.as_ref()))
}
}
#[derive(Debug)]
pub struct FederatedCodeExchange {
pub authorization: FederatedAuthorization,
pub redirect_after: Option<String>,
pub id_token: String,
}
#[derive(Debug)]
pub struct FederatedSessionTransaction {
pub authorization: FederatedAuthorization,
pub duration_seconds: usize,
pub session_policy: Option<String>,
}
#[derive(Debug)]
pub struct FederatedSession {
pub credentials: Credentials,
pub authorization: FederatedAuthorization,
}
#[derive(Debug)]
pub struct FederatedLoginSession {
pub session: FederatedSession,
pub redirect_after: Option<String>,
pub logout_token: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(username: &str, email: &str, sub: &str) -> FederatedClaims {
FederatedClaims {
sub: sub.to_string(),
email: email.to_string(),
username: username.to_string(),
groups: Vec::new(),
raw: HashMap::new(),
}
}
fn authorization(policies: Vec<String>, groups: Vec<String>) -> FederatedAuthorization {
FederatedAuthorization {
provider_id: "standard_oidc".to_string(),
claims: FederatedClaims {
raw: HashMap::from([("iss".to_string(), Value::String("https://idp.example.test".to_string()))]),
..claims("", "", "subject")
},
policies,
groups,
roles_claim_key: None,
roles: Vec::new(),
}
}
#[test]
fn session_identity_preserves_existing_fallback_order() {
assert_eq!(claims("john", "john@example.com", "sub-1").session_identity(), "john");
assert_eq!(claims("", "john@example.com", "sub-1").session_identity(), "john@example.com");
assert_eq!(claims("", "", "sub-1").session_identity(), "sub-1");
assert_eq!(claims("", "", "").session_identity(), "oidc-user-unknown");
}
#[test]
fn authorization_context_accepts_policy_or_group() {
assert!(!authorization(Vec::new(), 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());
}
#[test]
fn oidc_virtual_parent_matches_minio_and_is_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("TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA")
);
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());
}
}