refactor(iam): separate OIDC authentication mapping (#8416)

This commit is contained in:
GatewayJ
2026-10-08 21:21:22 +08:00
committed by GitHub
parent 1a450bf228
commit 5b4432620e
12 changed files with 948 additions and 290 deletions
+500
View File
@@ -0,0 +1,500 @@
// 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 super::{FederatedAuthorization, VerifiedFederatedIdentity};
use crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use std::{collections::HashMap, sync::Arc};
use tracing::debug;
#[derive(Clone)]
pub(crate) struct FederatedAuthorizationRule {
provider_id: String,
claim_name: String,
claim_prefix: String,
role_policy: String,
groups_claim: String,
roles_claim: String,
}
impl FederatedAuthorizationRule {
pub(crate) fn new(
provider_id: String,
claim_name: String,
claim_prefix: String,
role_policy: String,
groups_claim: String,
roles_claim: String,
) -> Self {
Self {
provider_id,
claim_name,
claim_prefix,
role_policy,
groups_claim,
roles_claim,
}
}
fn as_ref(&self) -> FederatedAuthorizationRuleRef<'_> {
FederatedAuthorizationRuleRef::new(
&self.claim_name,
&self.claim_prefix,
&self.role_policy,
&self.groups_claim,
&self.roles_claim,
)
}
}
#[derive(Clone, Copy)]
pub(crate) struct FederatedAuthorizationRuleRef<'a> {
claim_name: &'a str,
claim_prefix: &'a str,
role_policy: &'a str,
groups_claim: &'a str,
roles_claim: &'a str,
}
impl<'a> FederatedAuthorizationRuleRef<'a> {
pub(crate) fn new(
claim_name: &'a str,
claim_prefix: &'a str,
role_policy: &'a str,
groups_claim: &'a str,
roles_claim: &'a str,
) -> Self {
Self {
claim_name,
claim_prefix,
role_policy,
groups_claim,
roles_claim,
}
}
}
/// Immutable provider-specific inputs for core federation authorization mapping.
#[derive(Clone, Default)]
pub struct FederatedAuthorizationRules {
providers: Arc<HashMap<String, FederatedAuthorizationRule>>,
}
impl FederatedAuthorizationRules {
pub(crate) fn new(rules: impl IntoIterator<Item = FederatedAuthorizationRule>) -> Self {
Self {
providers: Arc::new(rules.into_iter().map(|rule| (rule.provider_id.clone(), rule)).collect()),
}
}
}
/// Maps a verified external identity into RustFS authorization data.
#[derive(Clone)]
pub struct CoreFederatedAuthorizationMapper {
rules: FederatedAuthorizationRules,
}
impl CoreFederatedAuthorizationMapper {
pub fn new(rules: FederatedAuthorizationRules) -> Self {
Self { rules }
}
pub(crate) fn map(&self, identity: VerifiedFederatedIdentity) -> FederatedAuthorization {
let (provider, claims) = identity.into_parts();
let provider_id = provider.into_string();
let Some(rule) = self.rules.providers.get(&provider_id).map(FederatedAuthorizationRule::as_ref) else {
return FederatedAuthorization {
provider_id,
claims,
policies: Vec::new(),
group_claim_policies: Vec::new(),
groups: Vec::new(),
roles_claim_key: None,
roles: Vec::new(),
};
};
let (policies, groups) = Self::map_policies_and_groups(&provider_id, rule, &claims.groups, &claims.raw, true);
let group_claim_policies = Self::group_claim_policy_names(rule, &claims.groups, &claims.raw);
let roles_claim_key = (!rule.roles_claim.trim().is_empty()).then(|| rule.roles_claim.trim().to_string());
let roles = roles_claim_key
.as_deref()
.map(|claim_name| role_values(&claims.raw, claim_name))
.unwrap_or_default();
FederatedAuthorization {
provider_id,
claims,
policies,
group_claim_policies,
groups,
roles_claim_key,
roles,
}
}
pub(crate) fn map_policies_and_groups(
provider_id: &str,
rule: FederatedAuthorizationRuleRef<'_>,
source_groups: &[String],
raw_claims: &HashMap<String, serde_json::Value>,
emit_diagnostics: bool,
) -> (Vec<String>, Vec<String>) {
let has_role_policy = !rule.role_policy.trim().is_empty();
let mut policies: Vec<String> = if has_role_policy {
rule.role_policy
.split(',')
.map(str::trim)
.filter(|policy| !policy.is_empty())
.map(ToOwned::to_owned)
.collect()
} else {
source_groups
.iter()
.map(|group| policy_name(rule.claim_prefix, group))
.collect()
};
if !has_role_policy && rule.claim_name != rule.groups_claim {
policies.extend(
claim_values(raw_claims, rule.claim_name)
.into_iter()
.map(|value| policy_name(rule.claim_prefix, &value)),
);
}
policies.sort();
policies.dedup();
let mut groups = source_groups.to_vec();
groups.sort();
groups.dedup();
if emit_diagnostics {
record_mapping_diagnostics(provider_id, rule, raw_claims, &policies, &groups);
}
(policies, groups)
}
pub(crate) fn group_claim_policy_names(
rule: FederatedAuthorizationRuleRef<'_>,
source_groups: &[String],
raw_claims: &HashMap<String, serde_json::Value>,
) -> Vec<String> {
if !rule.role_policy.trim().is_empty() {
return Vec::new();
}
let explicit_policy_names: Vec<String> = if rule.claim_name != rule.groups_claim {
claim_values(raw_claims, rule.claim_name)
.iter()
.map(|value| policy_name(rule.claim_prefix, value))
.collect()
} else {
Vec::new()
};
let mut group_policies: Vec<String> = source_groups
.iter()
.map(|group| policy_name(rule.claim_prefix, group))
.filter(|policy| !explicit_policy_names.contains(policy))
.collect();
group_policies.sort();
group_policies.dedup();
group_policies
}
}
fn record_mapping_diagnostics(
provider_id: &str,
rule: FederatedAuthorizationRuleRef<'_>,
raw_claims: &HashMap<String, serde_json::Value>,
policies: &[String],
groups: &[String],
) {
let (claim_name_lookup, claim_name_type) = claim_lookup_details(raw_claims, rule.claim_name);
let (groups_claim_lookup, groups_claim_type) = claim_lookup_details(raw_claims, rule.groups_claim);
let (roles_claim_lookup, roles_claim_type) = claim_lookup_details(raw_claims, rule.roles_claim);
let claim_name_value_count = claim_values(raw_claims, rule.claim_name).len();
let groups_claim_value_count = claim_values(raw_claims, rule.groups_claim).len();
let roles_claim_value_count = claim_values(raw_claims, rule.roles_claim).len();
debug!(
event = EVENT_OIDC_DIAGNOSTICS,
component = LOG_COMPONENT_IAM,
subsystem = LOG_SUBSYSTEM_OIDC,
result = "claims_policy_mapped",
provider_id,
claim_name = %rule.claim_name,
claim_prefix = %rule.claim_prefix,
groups_claim = %rule.groups_claim,
roles_claim = %rule.roles_claim,
role_policy_configured = !rule.role_policy.trim().is_empty(),
policy_count = policies.len(),
group_count = groups.len(),
raw_claim_key_count = raw_claims.len(),
claim_name_lookup,
claim_name_type,
claim_name_value_count,
groups_claim_lookup,
groups_claim_type,
groups_claim_value_count,
roles_claim_lookup,
roles_claim_type,
roles_claim_value_count,
"oidc claims mapped to policies"
);
}
fn policy_name(prefix: &str, value: &str) -> String {
format!("{prefix}{value}")
}
fn claim_values(claims: &HashMap<String, serde_json::Value>, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(serde_json::Value::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect(),
ClaimLookup::Found(serde_json::Value::String(value)) => value.split(',').map(str::trim).map(ToOwned::to_owned).collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
fn role_values(claims: &HashMap<String, serde_json::Value>, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(serde_json::Value::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect(),
ClaimLookup::Found(serde_json::Value::String(value)) => value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
fn claim_lookup_details(claims: &HashMap<String, serde_json::Value>, claim_name: &str) -> (&'static str, &'static str) {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(value) => ("found", claim_value_type(value)),
ClaimLookup::Missing => ("missing", "none"),
ClaimLookup::Ambiguous => ("ambiguous", "none"),
}
}
fn claim_value_type(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::federation::{FederatedClaims, FederatedProviderRef, VerifiedFederatedIdentity};
use serde_json::json;
fn mapper(rule: FederatedAuthorizationRule) -> CoreFederatedAuthorizationMapper {
CoreFederatedAuthorizationMapper::new(FederatedAuthorizationRules::new([rule]))
}
fn identity(provider_id: &str) -> VerifiedFederatedIdentity {
VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new(provider_id.to_string()),
FederatedClaims {
sub: "subject".to_string(),
email: "user@example.test".to_string(),
username: "user".to_string(),
groups: vec![
"source-ops".to_string(),
"source-developers".to_string(),
"source-ops".to_string(),
],
raw: HashMap::from([
("Policy".to_string(), json!(["explicit", "source-ops"])),
("Roles".to_string(), json!("reader, writer, ,auditor")),
]),
},
)
}
#[test]
fn claim_mapping_preserves_policy_group_role_and_case_behavior() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
"roles".to_string(),
));
let authorization = mapper.map(identity("corp"));
assert_eq!(
authorization.policies,
["mapped-explicit", "mapped-source-developers", "mapped-source-ops"]
);
assert_eq!(authorization.group_claim_policies, ["mapped-source-developers"]);
assert_eq!(authorization.groups, ["source-developers", "source-ops"]);
assert_eq!(authorization.roles_claim_key.as_deref(), Some("roles"));
assert_eq!(authorization.roles, ["reader", "writer", "auditor"]);
}
#[test]
fn role_policy_mode_keeps_groups_without_group_derived_policies() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
"mapped-".to_string(),
" readonly, readwrite, readonly ".to_string(),
"groups".to_string(),
String::new(),
));
let authorization = mapper.map(identity("corp"));
assert_eq!(authorization.policies, ["readonly", "readwrite"]);
assert!(authorization.group_claim_policies.is_empty());
assert_eq!(authorization.groups, ["source-developers", "source-ops"]);
}
#[test]
fn missing_provider_rules_preserve_identity_without_authorization_context() {
let authorization =
CoreFederatedAuthorizationMapper::new(FederatedAuthorizationRules::default()).map(identity("missing"));
assert_eq!(authorization.provider_id, "missing");
assert_eq!(authorization.claims.username, "user");
assert!(!authorization.has_authorization_context());
}
#[test]
fn exact_claim_name_wins_over_case_insensitive_candidates() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
String::new(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: Vec::new(),
raw: HashMap::from([
("policy".to_string(), json!("exact")),
("Policy".to_string(), json!("fallback")),
]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.policies, ["mapped-exact"]);
}
#[test]
fn ambiguous_case_insensitive_claim_name_is_ignored() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"pOlIcY".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
String::new(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: vec!["source-ops".to_string()],
raw: HashMap::from([
("Policy".to_string(), json!("first")),
("POLICY".to_string(), json!("second")),
]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.policies, ["mapped-source-ops"]);
assert_eq!(claim_lookup_details(&authorization.claims.raw, "pOlIcY"), ("ambiguous", "none"));
assert_eq!(claim_lookup_details(&authorization.claims.raw, "missing"), ("missing", "none"));
}
#[test]
fn comma_separated_claim_values_keep_existing_empty_value_behavior() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"Policy".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
String::new(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: Vec::new(),
raw: HashMap::from([("Policy".to_string(), json!(" explicit, ,audit "))]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.policies, ["mapped-", "mapped-audit", "mapped-explicit"]);
}
#[test]
fn role_array_values_keep_existing_empty_value_behavior() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
String::new(),
String::new(),
"groups".to_string(),
"roles".to_string(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: Vec::new(),
raw: HashMap::from([("roles".to_string(), json!(["reader", "", 7, "writer"]))]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.roles, ["reader", "", "writer"]);
}
}
+5
View File
@@ -14,6 +14,7 @@
mod binding;
mod error;
mod mapper;
mod model;
pub mod oidc;
mod provider;
@@ -22,10 +23,14 @@ mod transaction;
pub use binding::FederatedSessionBinding;
pub use error::{FederatedSessionBindingError, FederationError, Result};
pub use mapper::{CoreFederatedAuthorizationMapper, FederatedAuthorizationRules};
pub(crate) use mapper::{FederatedAuthorizationRule, FederatedAuthorizationRuleRef};
pub(crate) use model::OpaqueLogoutContinuation;
pub use model::{
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedLoginSession, FederatedProviderRef,
FederatedSession, FederatedSessionTransaction, OIDC_VIRTUAL_PARENT_CLAIM, VerifiedFederatedIdentity,
};
pub use provider::{FederatedIdentityProvider, FederatedProviderView, FederatedRedirectPolicy};
pub(crate) use provider::{StandardOidcAuthentication, VerifiedFederatedCodeExchange};
pub use registry::FederatedIdentityRegistry;
pub use transaction::FederatedIdentityService;
+16
View File
@@ -56,6 +56,10 @@ impl FederatedProviderRef {
pub fn as_str(&self) -> &str {
&self.provider_id
}
pub(crate) fn into_string(self) -> String {
self.provider_id
}
}
#[derive(Clone)]
@@ -80,6 +84,10 @@ impl fmt::Debug for VerifiedFederatedIdentity {
}
impl VerifiedFederatedIdentity {
pub(crate) fn from_claims(provider: FederatedProviderRef, claims: FederatedClaims) -> Self {
Self { provider, claims }
}
pub fn provider(&self) -> &FederatedProviderRef {
&self.provider
}
@@ -111,6 +119,10 @@ impl VerifiedFederatedIdentity {
pub fn session_identity(&self) -> String {
self.claims.session_identity()
}
pub(crate) fn into_parts(self) -> (FederatedProviderRef, FederatedClaims) {
(self.provider, self.claims)
}
}
#[derive(Debug, Clone)]
@@ -193,6 +205,10 @@ pub(crate) struct OpaqueLogoutContinuation {
}
impl OpaqueLogoutContinuation {
pub(crate) fn new(provider: FederatedProviderRef, id_token: String) -> Self {
Self { provider, id_token }
}
pub(crate) fn into_parts(self) -> (FederatedProviderRef, String) {
(self.provider, self.id_token)
}
+16 -93
View File
@@ -12,111 +12,39 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::config::roles_claim_key;
use crate::{
federation::{FederatedAuthorization, FederatedClaims},
oidc::{OidcClaims, OidcSys},
federation::{FederatedClaims, FederatedProviderRef, VerifiedFederatedIdentity},
oidc::OidcClaims,
};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
fn string_list_claim(claims: &OidcClaims, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(&claims.raw, claim_name) {
ClaimLookup::Found(serde_json::Value::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect(),
ClaimLookup::Found(serde_json::Value::String(value)) => value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
pub(super) fn authorization(oidc: &OidcSys, provider_id: String, claims: OidcClaims) -> FederatedAuthorization {
let (policies, groups) = oidc.map_claims_to_policies(&provider_id, &claims);
let group_claim_policies = oidc.group_claim_policy_names(&provider_id, &claims);
let roles_claim_key = roles_claim_key(oidc, &provider_id);
let roles = roles_claim_key
.as_deref()
.map(|claim_name| string_list_claim(&claims, claim_name))
.unwrap_or_default();
FederatedAuthorization {
provider_id,
claims: FederatedClaims {
pub(super) fn verified_identity(provider: FederatedProviderRef, claims: OidcClaims) -> VerifiedFederatedIdentity {
VerifiedFederatedIdentity::from_claims(
provider,
FederatedClaims {
sub: claims.sub,
email: claims.email,
username: claims.username,
groups: claims.groups,
raw: claims.raw,
},
policies,
group_claim_policies,
groups,
roles_claim_key,
roles,
}
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oidc::{make_test_sys, test_config};
use serde_json::json;
use std::collections::HashMap;
#[test]
fn string_list_claim_matches_existing_array_and_csv_behavior() {
let claims = OidcClaims {
raw: HashMap::from([
("array_roles".to_string(), json!(["reader", 7, "writer"])),
("csv_roles".to_string(), json!("reader, writer, ,auditor")),
]),
..Default::default()
};
assert_eq!(string_list_claim(&claims, "array_roles"), ["reader", "writer"]);
assert_eq!(string_list_claim(&claims, "csv_roles"), ["reader", "writer", "auditor"]);
}
#[test]
fn string_list_claim_preserves_exact_and_ambiguous_match_behavior() {
let exact = OidcClaims {
raw: HashMap::from([
("Roles".to_string(), json!(["mixed-case"])),
("roles".to_string(), json!(["exact-match"])),
]),
..Default::default()
};
assert_eq!(string_list_claim(&exact, "roles"), ["exact-match"]);
let ambiguous = OidcClaims {
raw: HashMap::from([
("Roles".to_string(), json!(["mixed-case"])),
("ROLES".to_string(), json!(["upper-case"])),
]),
..Default::default()
};
assert!(string_list_claim(&ambiguous, "roles").is_empty());
}
#[test]
fn authorization_preserves_verified_claims_and_keeps_source_groups_distinct() {
let mut config = test_config("corp");
config.claim_prefix = "mapped-".to_string();
config.roles_claim = "roles".to_string();
let oidc = make_test_sys(vec![config]);
fn verified_identity_preserves_provider_and_claim_values() {
let raw = HashMap::from([
("iss".to_string(), json!("https://corp.example.test")),
("department".to_string(), json!("engineering")),
("roles".to_string(), json!(["reader", "admin"])),
]);
let authorization = authorization(
&oidc,
"corp".to_string(),
let identity = verified_identity(
FederatedProviderRef::new("corp".to_string()),
OidcClaims {
sub: " subject-123 ".to_string(),
email: " user@example.test ".to_string(),
@@ -130,16 +58,11 @@ mod tests {
},
);
assert_eq!(authorization.provider_id, "corp");
assert_eq!(authorization.claims.sub, " subject-123 ");
assert_eq!(authorization.claims.email, " user@example.test ");
assert_eq!(authorization.claims.username, " user ");
assert_eq!(authorization.claims.groups, ["source-ops", "source-developers", "source-ops"]);
assert_eq!(authorization.claims.raw, raw);
assert_eq!(authorization.policies, ["mapped-source-developers", "mapped-source-ops"]);
assert_eq!(authorization.group_claim_policies, ["mapped-source-developers", "mapped-source-ops"]);
assert_eq!(authorization.groups, ["source-developers", "source-ops"]);
assert_eq!(authorization.roles_claim_key.as_deref(), Some("roles"));
assert_eq!(authorization.roles, ["reader", "admin"]);
assert_eq!(identity.provider().as_str(), "corp");
assert_eq!(identity.subject(), " subject-123 ");
assert_eq!(identity.email(), " user@example.test ");
assert_eq!(identity.username(), " user ");
assert_eq!(identity.source_groups(), ["source-ops", "source-developers", "source-ops"]);
assert_eq!(identity.attributes(), &raw);
}
}
+14 -13
View File
@@ -13,10 +13,23 @@
// limitations under the License.
use crate::{
federation::FederatedRedirectPolicy,
federation::{FederatedAuthorizationRule, FederatedAuthorizationRules, FederatedRedirectPolicy},
oidc::{OidcConfigSnapshot, OidcSys},
};
pub(crate) fn authorization_rules(oidc: &OidcSys) -> FederatedAuthorizationRules {
FederatedAuthorizationRules::new(oidc.provider_configs().map(|config| {
FederatedAuthorizationRule::new(
config.id.clone(),
config.claim_name.clone(),
config.claim_prefix.clone(),
config.role_policy.clone(),
config.groups_claim.clone(),
config.roles_claim.clone(),
)
}))
}
/// Read-only access to the active standard OIDC configuration.
///
/// This interface stays separate from authentication and generic provider
@@ -32,22 +45,10 @@ pub(super) fn redirect_policy(oidc: &OidcSys, provider_id: &str) -> Option<Feder
})
}
pub(super) fn roles_claim_key(oidc: &OidcSys, provider_id: &str) -> Option<String> {
oidc.get_provider_config(provider_id)
.map(|config| config.roles_claim.trim().to_string())
.filter(|claim| !claim.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roles_claim_key_requires_explicit_provider_config() {
let oidc = OidcSys::empty().expect("empty OIDC configuration should be valid");
assert_eq!(roles_claim_key(&oidc, "default"), None);
}
#[test]
fn redirect_policy_keeps_configured_uri_and_dynamic_setting() {
let mut config = crate::oidc::test_config("default");
+55 -12
View File
@@ -16,8 +16,10 @@ use super::config::OidcConfigQuery;
use super::{claims, config, discovery, http};
use crate::{
federation::{
FederatedAuthorization, FederatedCodeExchange, FederatedIdentityProvider, FederatedProviderView, FederatedRedirectPolicy,
FederationError, Result,
CoreFederatedAuthorizationMapper, FederatedAuthorization, FederatedAuthorizationRules, FederatedCodeExchange,
FederatedIdentityProvider, FederatedIdentityService, FederatedProviderRef, FederatedProviderView,
FederatedRedirectPolicy, FederationError, OpaqueLogoutContinuation, Result, StandardOidcAuthentication,
VerifiedFederatedCodeExchange,
},
oidc::{OidcConfigSnapshot, OidcSys},
};
@@ -25,11 +27,26 @@ use std::sync::Arc;
pub struct StandardOidcAdapter {
oidc: Arc<OidcSys>,
authorization_rules: FederatedAuthorizationRules,
}
impl StandardOidcAdapter {
pub fn new(oidc: Arc<OidcSys>) -> Self {
Self { oidc }
let authorization_rules = config::authorization_rules(&oidc);
Self {
oidc,
authorization_rules,
}
}
pub fn authorization_rules(&self) -> FederatedAuthorizationRules {
self.authorization_rules.clone()
}
pub fn into_service(self: Arc<Self>, mapper: CoreFederatedAuthorizationMapper) -> FederatedIdentityService {
let authentication: Arc<dyn StandardOidcAuthentication> = self.clone();
let provider: Arc<dyn FederatedIdentityProvider> = self;
FederatedIdentityService::from_standard_oidc_parts(provider, authentication, mapper)
}
}
@@ -39,6 +56,33 @@ impl OidcConfigQuery for StandardOidcAdapter {
}
}
#[async_trait::async_trait]
impl StandardOidcAuthentication for StandardOidcAdapter {
async fn exchange_identity(&self, state: &str, code: &str, redirect_uri: &str) -> Result<VerifiedFederatedCodeExchange> {
let (oidc_claims, provider_id, session, id_token) = http::exchange_code(&self.oidc, state, code, redirect_uri).await?;
let provider = FederatedProviderRef::new(provider_id);
Ok(VerifiedFederatedCodeExchange::new(
claims::verified_identity(provider.clone(), oidc_claims),
session.redirect_after,
OpaqueLogoutContinuation::new(provider, id_token),
))
}
async fn verify_identity(&self, jwt: &str) -> Result<crate::federation::VerifiedFederatedIdentity> {
let (oidc_claims, provider_id) = self
.oidc
.verify_web_identity_token(jwt)
.await
.map_err(FederationError::TokenVerification)?;
Ok(claims::verified_identity(FederatedProviderRef::new(provider_id), oidc_claims))
}
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String> {
let (provider, id_token) = continuation.into_parts();
http::create_logout_token(&self.oidc, provider.as_str(), &id_token).await
}
}
#[async_trait::async_trait]
impl FederatedIdentityProvider for StandardOidcAdapter {
fn has_providers(&self) -> bool {
@@ -66,21 +110,20 @@ impl FederatedIdentityProvider for StandardOidcAdapter {
}
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<FederatedCodeExchange> {
let (oidc_claims, provider_id, session, id_token) = http::exchange_code(&self.oidc, state, code, redirect_uri).await?;
let exchange = StandardOidcAuthentication::exchange_identity(self, state, code, redirect_uri).await?;
let (identity, redirect_after, continuation) = exchange.into_parts();
let authorization = CoreFederatedAuthorizationMapper::new(self.authorization_rules()).map(identity);
let (_, id_token) = continuation.into_parts();
Ok(FederatedCodeExchange {
authorization: claims::authorization(&self.oidc, provider_id, oidc_claims),
redirect_after: session.redirect_after,
authorization,
redirect_after,
id_token,
})
}
async fn verify_web_identity_token(&self, jwt: &str) -> Result<FederatedAuthorization> {
let (oidc_claims, provider_id) = self
.oidc
.verify_web_identity_token(jwt)
.await
.map_err(FederationError::TokenVerification)?;
Ok(claims::authorization(&self.oidc, provider_id, oidc_claims))
let identity = StandardOidcAuthentication::verify_identity(self, jwt).await?;
Ok(CoreFederatedAuthorizationMapper::new(self.authorization_rules()).map(identity))
}
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String> {
+32 -1
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{FederatedAuthorization, FederatedCodeExchange, Result};
use super::{FederatedAuthorization, FederatedCodeExchange, OpaqueLogoutContinuation, Result, VerifiedFederatedIdentity};
use crate::oidc::OidcProviderConfig;
use serde::{Deserialize, Serialize};
@@ -30,6 +30,37 @@ pub struct FederatedRedirectPolicy {
pub allow_request_origin: bool,
}
pub(crate) struct VerifiedFederatedCodeExchange {
identity: VerifiedFederatedIdentity,
redirect_after: Option<String>,
logout_continuation: OpaqueLogoutContinuation,
}
impl VerifiedFederatedCodeExchange {
pub(crate) fn new(
identity: VerifiedFederatedIdentity,
redirect_after: Option<String>,
logout_continuation: OpaqueLogoutContinuation,
) -> Self {
Self {
identity,
redirect_after,
logout_continuation,
}
}
pub(crate) fn into_parts(self) -> (VerifiedFederatedIdentity, Option<String>, OpaqueLogoutContinuation) {
(self.identity, self.redirect_after, self.logout_continuation)
}
}
#[async_trait::async_trait]
pub(crate) trait StandardOidcAuthentication: Send + Sync {
async fn exchange_identity(&self, state: &str, code: &str, redirect_uri: &str) -> Result<VerifiedFederatedCodeExchange>;
async fn verify_identity(&self, jwt: &str) -> Result<VerifiedFederatedIdentity>;
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String>;
}
#[async_trait::async_trait]
pub trait FederatedIdentityProvider: Send + Sync {
fn has_providers(&self) -> bool;
+4 -3
View File
@@ -15,7 +15,8 @@
use super::FederatedIdentityProvider;
use std::sync::Arc;
/// Immutable registry of built-in federation adapters.
/// Compatibility facade for integrations built against the original provider API.
/// Production standard OIDC startup composes the adapter, mapper, and service directly.
pub struct FederatedIdentityRegistry {
standard_oidc: Arc<dyn FederatedIdentityProvider>,
}
@@ -25,7 +26,7 @@ impl FederatedIdentityRegistry {
Self { standard_oidc }
}
pub(crate) fn standard_oidc(&self) -> &dyn FederatedIdentityProvider {
self.standard_oidc.as_ref()
pub(crate) fn standard_oidc_arc(&self) -> Arc<dyn FederatedIdentityProvider> {
Arc::clone(&self.standard_oidc)
}
}
+279 -30
View File
@@ -13,10 +13,12 @@
// limitations under the License.
use super::{
FederatedIdentityRegistry, FederatedLoginSession, FederatedProviderView, FederatedRedirectPolicy, FederatedSession,
FederatedSessionBinding, FederatedSessionTransaction, FederationError, Result,
CoreFederatedAuthorizationMapper, FederatedAuthorization, FederatedIdentityProvider, FederatedIdentityRegistry,
FederatedLoginSession, FederatedProviderView, FederatedRedirectPolicy, FederatedSession, FederatedSessionBinding,
FederatedSessionTransaction, FederationError, OpaqueLogoutContinuation, Result, StandardOidcAuthentication,
};
use crate::oidc::OidcProviderConfig;
use std::sync::Arc;
const DEFAULT_OIDC_PROVIDER_ID: &str = "default";
@@ -30,42 +32,108 @@ fn sorted_provider_views(mut providers: Vec<FederatedProviderView>) -> Vec<Feder
}
pub struct FederatedIdentityService {
registry: FederatedIdentityRegistry,
provider: Arc<dyn FederatedIdentityProvider>,
standard_oidc: Option<StandardOidcRuntime>,
}
struct StandardOidcRuntime {
authentication: Arc<dyn StandardOidcAuthentication>,
mapper: CoreFederatedAuthorizationMapper,
}
struct RuntimeCodeExchange {
authorization: FederatedAuthorization,
redirect_after: Option<String>,
logout_continuation: OpaqueLogoutContinuation,
}
impl FederatedIdentityService {
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<RuntimeCodeExchange> {
match &self.standard_oidc {
Some(runtime) => {
let exchange = runtime.authentication.exchange_identity(state, code, redirect_uri).await?;
let (identity, redirect_after, logout_continuation) = exchange.into_parts();
Ok(RuntimeCodeExchange {
authorization: runtime.mapper.map(identity),
redirect_after,
logout_continuation,
})
}
None => {
let exchange = self.provider.exchange_code(state, code, redirect_uri).await?;
let (authorization, redirect_after, logout_continuation) = exchange.into_parts();
Ok(RuntimeCodeExchange {
authorization,
redirect_after,
logout_continuation,
})
}
}
}
async fn verify_identity(&self, jwt: &str) -> Result<FederatedAuthorization> {
match &self.standard_oidc {
Some(runtime) => Ok(runtime.mapper.map(runtime.authentication.verify_identity(jwt).await?)),
None => self.provider.verify_web_identity_token(jwt).await,
}
}
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String> {
match &self.standard_oidc {
Some(runtime) => runtime.authentication.create_logout_token(continuation).await,
None => {
let (provider, id_token) = continuation.into_parts();
self.provider.create_logout_token(provider.as_str(), &id_token).await
}
}
}
/// Build the compatibility service around the published provider interface.
pub fn new(registry: FederatedIdentityRegistry) -> Self {
Self { registry }
Self {
provider: registry.standard_oidc_arc(),
standard_oidc: None,
}
}
pub(super) fn from_standard_oidc_parts(
provider: Arc<dyn FederatedIdentityProvider>,
authentication: Arc<dyn StandardOidcAuthentication>,
mapper: CoreFederatedAuthorizationMapper,
) -> Self {
Self {
provider,
standard_oidc: Some(StandardOidcRuntime { authentication, mapper }),
}
}
pub fn has_providers(&self) -> bool {
self.registry.standard_oidc().has_providers()
self.provider.has_providers()
}
pub fn list_providers(&self) -> Vec<FederatedProviderView> {
sorted_provider_views(self.registry.standard_oidc().list_providers())
let providers = self.provider.list_providers();
sorted_provider_views(providers)
}
pub fn list_visible_providers(&self) -> Vec<FederatedProviderView> {
sorted_provider_views(self.registry.standard_oidc().list_visible_providers())
let providers = self.provider.list_visible_providers();
sorted_provider_views(providers)
}
pub fn redirect_policy(&self, provider_id: &str) -> Option<FederatedRedirectPolicy> {
self.registry.standard_oidc().redirect_policy(provider_id)
self.provider.redirect_policy(provider_id)
}
/// Compatibility accessor for existing federation consumers.
/// Login redirects use [`Self::redirect_policy`], while OIDC-specific
/// configuration consumers use the OIDC configuration query.
pub fn get_provider_config(&self, provider_id: &str) -> Option<&OidcProviderConfig> {
self.registry.standard_oidc().provider_config(provider_id)
self.provider.provider_config(provider_id)
}
pub async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String> {
self.registry
.standard_oidc()
.authorize_url(provider_id, redirect_uri, redirect_after)
.await
self.provider.authorize_url(provider_id, redirect_uri, redirect_after).await
}
pub async fn complete_authorization_code(
@@ -76,27 +144,20 @@ impl FederatedIdentityService {
duration_seconds: usize,
binding: &dyn FederatedSessionBinding,
) -> Result<FederatedLoginSession> {
let exchange = self.registry.standard_oidc().exchange_code(state, code, redirect_uri).await?;
let (authorization, redirect_after, logout_continuation) = exchange.into_parts();
let exchange = self.exchange_code(state, code, redirect_uri).await?;
let transaction = FederatedSessionTransaction {
authorization,
authorization: exchange.authorization,
duration_seconds,
session_policy: None,
};
let credentials = binding.bind(&transaction).await?;
let (provider, id_token) = logout_continuation.into_parts();
let logout_token = self
.registry
.standard_oidc()
.create_logout_token(provider.as_str(), &id_token)
.await?;
let logout_token = self.create_logout_token(exchange.logout_continuation).await?;
Ok(FederatedLoginSession {
session: FederatedSession {
credentials,
authorization: transaction.authorization,
},
redirect_after,
redirect_after: exchange.redirect_after,
logout_token,
})
}
@@ -108,7 +169,7 @@ impl FederatedIdentityService {
session_policy: Option<String>,
binding: &dyn FederatedSessionBinding,
) -> Result<FederatedSession> {
let authorization = self.registry.standard_oidc().verify_web_identity_token(jwt).await?;
let authorization = self.verify_identity(jwt).await?;
if !authorization.has_authorization_context() {
tracing::warn!(
provider_id = %authorization.provider_id,
@@ -144,10 +205,7 @@ impl FederatedIdentityService {
}
pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>> {
self.registry
.standard_oidc()
.build_logout_url(logout_token, post_logout_redirect_uri)
.await
self.provider.build_logout_url(logout_token, post_logout_redirect_uri).await
}
}
@@ -155,7 +213,9 @@ impl FederatedIdentityService {
mod tests {
use super::*;
use crate::federation::{
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedIdentityProvider, FederatedSessionBindingError,
FederatedAuthorization, FederatedAuthorizationRule, FederatedAuthorizationRules, FederatedClaims, FederatedCodeExchange,
FederatedIdentityProvider, FederatedProviderRef, FederatedSessionBindingError, VerifiedFederatedCodeExchange,
VerifiedFederatedIdentity,
};
use rustfs_credentials::Credentials;
use std::sync::{Arc, Mutex};
@@ -224,6 +284,23 @@ mod tests {
roles: Vec::new(),
}
}
fn identity(&self, provider_id: &str) -> VerifiedFederatedIdentity {
VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new(provider_id.to_string()),
FederatedClaims {
sub: "subject".to_string(),
email: String::new(),
username: "user".to_string(),
groups: if self.with_group {
vec!["developers".to_string()]
} else {
Vec::new()
},
raw: Default::default(),
},
)
}
}
fn provider_views(provider_ids: &[&str]) -> Vec<FederatedProviderView> {
@@ -300,10 +377,76 @@ mod tests {
}
}
#[async_trait::async_trait]
impl StandardOidcAuthentication for TestProvider {
async fn exchange_identity(
&self,
_state: &str,
_code: &str,
_redirect_uri: &str,
) -> Result<VerifiedFederatedCodeExchange> {
self.record("exchange");
if self.failure == ProviderFailure::Exchange {
return Err(FederationError::CodeExchange("exchange failed".to_string()));
}
let provider = FederatedProviderRef::new(self.browser_provider_id.to_string());
Ok(VerifiedFederatedCodeExchange::new(
self.identity(self.browser_provider_id),
Some("/browser".to_string()),
OpaqueLogoutContinuation::new(provider, "id-token".to_string()),
))
}
async fn verify_identity(&self, _jwt: &str) -> Result<VerifiedFederatedIdentity> {
self.record("verify");
if self.failure == ProviderFailure::Verification {
return Err(FederationError::TokenVerification("verification failed".to_string()));
}
Ok(self.identity(self.web_provider_id))
}
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String> {
self.record("logout");
let (provider, id_token) = continuation.into_parts();
assert_eq!((provider.as_str(), id_token.as_str()), self.expected_logout);
if self.failure == ProviderFailure::Logout {
return Err(FederationError::Logout("logout failed".to_string()));
}
Ok("logout-token".to_string())
}
}
fn standard_service(provider: Arc<TestProvider>) -> FederatedIdentityService {
let mut provider_ids = vec![provider.browser_provider_id, provider.web_provider_id];
provider_ids.sort_unstable();
provider_ids.dedup();
let role_policy = if provider.with_policy { "readwrite" } else { "" };
let rules = FederatedAuthorizationRules::new(provider_ids.into_iter().map(|provider_id| {
FederatedAuthorizationRule::new(
provider_id.to_string(),
"groups".to_string(),
String::new(),
role_policy.to_string(),
"groups".to_string(),
String::new(),
)
}));
let authentication: Arc<dyn StandardOidcAuthentication> = provider.clone();
let provider: Arc<dyn FederatedIdentityProvider> = provider;
FederatedIdentityService {
provider,
standard_oidc: Some(StandardOidcRuntime {
authentication,
mapper: CoreFederatedAuthorizationMapper::new(rules),
}),
}
}
struct RecordingBinding {
fail: bool,
events: Arc<Mutex<Vec<&'static str>>>,
transactions: Mutex<Vec<(String, usize, Option<String>)>>,
mapped_policies: Mutex<Vec<Vec<String>>>,
}
impl RecordingBinding {
@@ -312,6 +455,7 @@ mod tests {
fail: false,
events,
transactions: Mutex::new(Vec::new()),
mapped_policies: Mutex::new(Vec::new()),
}
}
}
@@ -328,6 +472,10 @@ mod tests {
transaction.duration_seconds,
transaction.session_policy.clone(),
));
self.mapped_policies
.lock()
.expect("mapped policies should not be poisoned")
.push(transaction.authorization.policies.clone());
if self.fail {
return Err(FederatedSessionBindingError::Internal("binding failed".to_string()));
}
@@ -416,6 +564,107 @@ mod tests {
);
}
#[tokio::test]
async fn standard_runtime_maps_before_common_browser_and_web_identity_flows() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut provider = TestProvider::new(events.clone());
provider.browser_provider_id = "corp";
provider.web_provider_id = "partner";
provider.expected_logout = ("corp", "id-token");
let binding = Arc::new(RecordingBinding::new(events.clone()));
let service = standard_service(Arc::new(provider));
let login = service
.complete_authorization_code("state", "code", "https://console.example/callback", 3600, binding.as_ref())
.await
.expect("standard callback flow should complete");
assert_eq!(login.session.authorization.provider_id, "corp");
assert_eq!(login.session.authorization.policies, ["readwrite"]);
assert_eq!(
events.lock().expect("event log should not be poisoned").as_slice(),
["exchange", "bind", "logout"]
);
events.lock().expect("event log should not be poisoned").clear();
let web_identity = service
.assume_role_with_web_identity("jwt", 7200, Some("session-policy".to_string()), binding.as_ref())
.await
.expect("standard web identity flow should complete");
assert_eq!(web_identity.authorization.provider_id, "partner");
assert_eq!(web_identity.authorization.policies, ["readwrite"]);
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify", "bind"]);
assert_eq!(
binding
.mapped_policies
.lock()
.expect("mapped policies should not be poisoned")
.as_slice(),
[vec!["readwrite".to_string()], vec!["readwrite".to_string()]]
);
}
#[tokio::test]
async fn standard_runtime_checks_authorization_before_binding() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut provider = TestProvider::new(events.clone());
provider.with_policy = false;
let binding = Arc::new(RecordingBinding::new(events.clone()));
let service = standard_service(Arc::new(provider));
let error = service
.assume_role_with_web_identity("jwt", 3600, None, binding.as_ref())
.await
.expect_err("standard web identity requires mapped authorization");
assert!(matches!(error, FederationError::NoAuthorizationContext));
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify"]);
}
#[tokio::test]
async fn standard_runtime_preserves_browser_failure_order() {
for (provider_failure, binding_failure, expected_events) in [
(ProviderFailure::Exchange, false, vec!["exchange"]),
(ProviderFailure::None, true, vec!["exchange", "bind"]),
(ProviderFailure::Logout, false, vec!["exchange", "bind", "logout"]),
] {
let events = Arc::new(Mutex::new(Vec::new()));
let mut provider = TestProvider::new(events.clone());
provider.failure = provider_failure;
let mut binding = RecordingBinding::new(events.clone());
binding.fail = binding_failure;
let service = standard_service(Arc::new(provider));
service
.complete_authorization_code("state", "code", "https://console.example/callback", 3600, &binding)
.await
.expect_err("the configured standard callback failure should be returned");
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), expected_events);
}
}
#[tokio::test]
async fn standard_runtime_preserves_web_identity_failure_order() {
for (provider_failure, binding_failure, expected_events) in [
(ProviderFailure::Verification, false, vec!["verify"]),
(ProviderFailure::None, true, vec!["verify", "bind"]),
] {
let events = Arc::new(Mutex::new(Vec::new()));
let mut provider = TestProvider::new(events.clone());
provider.failure = provider_failure;
let mut binding = RecordingBinding::new(events.clone());
binding.fail = binding_failure;
let service = standard_service(Arc::new(provider));
service
.assume_role_with_web_identity("jwt", 3600, None, &binding)
.await
.expect_err("the configured standard web identity failure should be returned");
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), expected_events);
}
}
#[tokio::test]
async fn web_identity_without_policy_or_group_is_not_bound() {
let events = Arc::new(Mutex::new(Vec::new()));
+3 -2
View File
@@ -20,10 +20,11 @@ use store::object::ObjectStore;
use sys::IamSys;
use tracing::{debug, error, info, instrument, warn};
const LOG_COMPONENT_IAM: &str = "iam";
pub(crate) const LOG_COMPONENT_IAM: &str = "iam";
const LOG_SUBSYSTEM_RUNTIME: &str = "runtime";
const LOG_SUBSYSTEM_OIDC: &str = "oidc";
pub(crate) const LOG_SUBSYSTEM_OIDC: &str = "oidc";
const EVENT_IAM_STATE: &str = "iam_state";
pub(crate) const EVENT_OIDC_DIAGNOSTICS: &str = "oidc_diagnostics";
const EVENT_OIDC_STATE: &str = "oidc_state";
pub mod cache;
+21 -133
View File
@@ -18,7 +18,11 @@
//! `openidconnect` crate for standards-compliant discovery, token exchange,
//! and ID token verification.
use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
use crate::{
EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC,
federation::{CoreFederatedAuthorizationMapper, FederatedAuthorizationRuleRef},
oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore},
};
use openidconnect::core::{
CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm,
};
@@ -47,9 +51,6 @@ use tokio::time::sleep;
use tracing::{debug, error, warn};
use url::Url;
const LOG_COMPONENT_IAM: &str = "iam";
const LOG_SUBSYSTEM_OIDC: &str = "oidc";
const EVENT_OIDC_DIAGNOSTICS: &str = "oidc_diagnostics";
const EVENT_OIDC_HTTP: &str = "oidc_http";
const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60);
const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3;
@@ -1502,88 +1503,10 @@ impl OidcSys {
/// Map OIDC claims to rustfs policy names.
pub fn map_claims_to_policies(&self, provider_id: &str, claims: &OidcClaims) -> (Vec<String>, Vec<String>) {
let config = match self.get_provider_config(provider_id) {
Some(c) => c,
None => return (vec![], vec![]),
let Some(rule) = self.authorization_rule(provider_id) else {
return (Vec::new(), Vec::new());
};
let mut policies = Vec::new();
let mut groups = Vec::new();
// Role-policy and claim-based authorization are separate OIDC modes. When a
// role policy is configured, group claims still provide group context but
// must not also become policy names.
let has_role_policy = !config.role_policy.trim().is_empty();
if has_role_policy {
for policy in config.role_policy.split(',') {
let policy = policy.trim();
if !policy.is_empty() {
policies.push(policy.to_string());
}
}
}
for group in &claims.groups {
groups.push(group.clone());
if !has_role_policy {
policies.push(claim_policy_name(&config.claim_prefix, group));
}
}
if !has_role_policy && config.claim_name != config.groups_claim {
for val in extract_groups_claim(&claims.raw, &config.claim_name) {
policies.push(claim_policy_name(&config.claim_prefix, &val));
}
}
// Deduplicate
policies.sort();
policies.dedup();
groups.sort();
groups.dedup();
let mut raw_claim_keys: Vec<&str> = claims.raw.keys().map(String::as_str).collect();
raw_claim_keys.sort_unstable();
let (claim_name_lookup, claim_name_raw_value) = claim_lookup_for_log(&claims.raw, &config.claim_name);
let (groups_claim_lookup, groups_claim_raw_value) = claim_lookup_for_log(&claims.raw, &config.groups_claim);
let (roles_claim_lookup, roles_claim_raw_value) = claim_lookup_for_log(&claims.raw, &config.roles_claim);
let claim_name_values = extract_groups_claim(&claims.raw, &config.claim_name);
let groups_claim_values = extract_groups_claim(&claims.raw, &config.groups_claim);
let roles_claim_values = extract_groups_claim(&claims.raw, &config.roles_claim);
debug!(
event = EVENT_OIDC_DIAGNOSTICS,
component = LOG_COMPONENT_IAM,
subsystem = LOG_SUBSYSTEM_OIDC,
result = "claims_policy_mapped",
provider_id = %provider_id,
claim_name = %config.claim_name,
claim_prefix = %config.claim_prefix,
groups_claim = %config.groups_claim,
roles_claim = %config.roles_claim,
role_policy = %config.role_policy,
policy_count = policies.len(),
group_count = groups.len(),
policies = ?policies,
groups = ?groups,
raw_claim_keys = ?raw_claim_keys,
raw_claims = ?claims.raw,
claim_name_lookup = %claim_name_lookup,
claim_name_type = claim_value_type_for_log(claim_name_raw_value),
claim_name_value = ?claim_name_raw_value,
claim_name_values = ?claim_name_values,
groups_claim_lookup = %groups_claim_lookup,
groups_claim_type = claim_value_type_for_log(groups_claim_raw_value),
groups_claim_value = ?groups_claim_raw_value,
groups_claim_values = ?groups_claim_values,
roles_claim_lookup = %roles_claim_lookup,
roles_claim_type = claim_value_type_for_log(roles_claim_raw_value),
roles_claim_value = ?roles_claim_raw_value,
roles_claim_values = ?roles_claim_values,
"oidc claims mapped to policies"
);
(policies, groups)
CoreFederatedAuthorizationMapper::map_policies_and_groups(provider_id, rule, &claims.groups, &claims.raw, true)
}
/// Policy names produced only by the canonical groups in claim-based mode: the groups claim
@@ -1595,30 +1518,22 @@ impl OidcSys {
/// dedicated policy claim are never included, so those configurations keep requiring every
/// policy to resolve.
pub fn group_claim_policy_names(&self, provider_id: &str, claims: &OidcClaims) -> Vec<String> {
let Some(config) = self.get_provider_config(provider_id) else {
let Some(rule) = self.authorization_rule(provider_id) else {
return Vec::new();
};
if !config.role_policy.trim().is_empty() {
return Vec::new();
}
CoreFederatedAuthorizationMapper::group_claim_policy_names(rule, &claims.groups, &claims.raw)
}
let explicit_policy_names: Vec<String> = if config.claim_name != config.groups_claim {
extract_groups_claim(&claims.raw, &config.claim_name)
.iter()
.map(|value| claim_policy_name(&config.claim_prefix, value))
.collect()
} else {
Vec::new()
};
let mut policies: Vec<String> = claims
.groups
.iter()
.map(|group| claim_policy_name(&config.claim_prefix, group))
.filter(|policy| !explicit_policy_names.contains(policy))
.collect();
policies.sort();
policies.dedup();
policies
fn authorization_rule(&self, provider_id: &str) -> Option<FederatedAuthorizationRuleRef<'_>> {
self.get_provider_config(provider_id).map(|config| {
FederatedAuthorizationRuleRef::new(
&config.claim_name,
&config.claim_prefix,
&config.role_policy,
&config.groups_claim,
&config.roles_claim,
)
})
}
/// Verify a raw JWT (id_token) for the AssumeRoleWithWebIdentity flow.
@@ -2415,10 +2330,6 @@ fn extract_string_claim(claims: &HashMap<String, serde_json::Value>, key: &str)
}
}
fn claim_policy_name(claim_prefix: &str, value: &str) -> String {
format!("{claim_prefix}{value}")
}
/// Extract a groups/array claim from raw claims with case-insensitive fallback. Handles both string arrays and single strings.
fn extract_groups_claim(claims: &HashMap<String, serde_json::Value>, key: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, key) {
@@ -2443,29 +2354,6 @@ fn extract_canonical_group_values(
groups
}
fn claim_lookup_for_log<'a>(
claims: &'a HashMap<String, serde_json::Value>,
key: &str,
) -> (&'static str, Option<&'a serde_json::Value>) {
match get_claim_case_insensitive(claims, key) {
ClaimLookup::Found(value) => ("found", Some(value)),
ClaimLookup::Missing => ("missing", None),
ClaimLookup::Ambiguous => ("ambiguous", None),
}
}
fn claim_value_type_for_log(value: Option<&serde_json::Value>) -> &'static str {
match value {
Some(serde_json::Value::Null) => "null",
Some(serde_json::Value::Bool(_)) => "bool",
Some(serde_json::Value::Number(_)) => "number",
Some(serde_json::Value::String(_)) => "string",
Some(serde_json::Value::Array(_)) => "array",
Some(serde_json::Value::Object(_)) => "object",
None => "none",
}
}
#[cfg(test)]
pub(crate) fn make_test_sys(configs: Vec<OidcProviderConfig>) -> OidcSys {
let configs = configs
+3 -3
View File
@@ -14,7 +14,7 @@
use rustfs_iam::{
federation::{
FederatedIdentityRegistry, FederatedIdentityService,
CoreFederatedAuthorizationMapper, FederatedIdentityService,
oidc::{OidcConfigQuery, StandardOidcAdapter},
},
get_oidc, init_oidc_sys_with_extra_root_ca_provider,
@@ -80,8 +80,8 @@ pub(crate) async fn init_auth_integrations() -> Result<()> {
fn standard_oidc_runtime(oidc: Arc<OidcSys>) -> (Arc<FederatedIdentityService>, Arc<dyn OidcConfigQuery>) {
let adapter = Arc::new(StandardOidcAdapter::new(oidc));
let oidc_config_query = adapter.clone();
let registry = FederatedIdentityRegistry::new(adapter);
(Arc::new(FederatedIdentityService::new(registry)), oidc_config_query)
let mapper = CoreFederatedAuthorizationMapper::new(adapter.authorization_rules());
(Arc::new(adapter.into_service(mapper)), oidc_config_query)
}
pub(crate) fn oidc_extra_root_ca_provider() -> OidcExtraRootCaProvider {