diff --git a/crates/iam/src/federation/mapper.rs b/crates/iam/src/federation/mapper.rs new file mode 100644 index 000000000..1dca51e5c --- /dev/null +++ b/crates/iam/src/federation/mapper.rs @@ -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>, +} + +impl FederatedAuthorizationRules { + pub(crate) fn new(rules: impl IntoIterator) -> 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, + emit_diagnostics: bool, + ) -> (Vec, Vec) { + let has_role_policy = !rule.role_policy.trim().is_empty(); + let mut policies: Vec = 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, + ) -> Vec { + if !rule.role_policy.trim().is_empty() { + return Vec::new(); + } + + let explicit_policy_names: Vec = 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 = 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, + 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, claim_name: &str) -> Vec { + 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, claim_name: &str) -> Vec { + 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, 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"]); + } +} diff --git a/crates/iam/src/federation/mod.rs b/crates/iam/src/federation/mod.rs index a80956312..246b10de5 100644 --- a/crates/iam/src/federation/mod.rs +++ b/crates/iam/src/federation/mod.rs @@ -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; diff --git a/crates/iam/src/federation/model.rs b/crates/iam/src/federation/model.rs index 23660999f..acaef1c16 100644 --- a/crates/iam/src/federation/model.rs +++ b/crates/iam/src/federation/model.rs @@ -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) } diff --git a/crates/iam/src/federation/oidc/claims.rs b/crates/iam/src/federation/oidc/claims.rs index c5659424b..210bef001 100644 --- a/crates/iam/src/federation/oidc/claims.rs +++ b/crates/iam/src/federation/oidc/claims.rs @@ -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 { - 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); } } diff --git a/crates/iam/src/federation/oidc/config.rs b/crates/iam/src/federation/oidc/config.rs index 25b80f9d9..f2374c251 100644 --- a/crates/iam/src/federation/oidc/config.rs +++ b/crates/iam/src/federation/oidc/config.rs @@ -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 Option { - 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"); diff --git a/crates/iam/src/federation/oidc/flow.rs b/crates/iam/src/federation/oidc/flow.rs index e2d50d432..1e2e49d44 100644 --- a/crates/iam/src/federation/oidc/flow.rs +++ b/crates/iam/src/federation/oidc/flow.rs @@ -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, + authorization_rules: FederatedAuthorizationRules, } impl StandardOidcAdapter { pub fn new(oidc: Arc) -> 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, mapper: CoreFederatedAuthorizationMapper) -> FederatedIdentityService { + let authentication: Arc = self.clone(); + let provider: Arc = 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 { + 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 { + 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 { + 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 { - 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 { - 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 { diff --git a/crates/iam/src/federation/provider.rs b/crates/iam/src/federation/provider.rs index f463032f5..267c94f75 100644 --- a/crates/iam/src/federation/provider.rs +++ b/crates/iam/src/federation/provider.rs @@ -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, + logout_continuation: OpaqueLogoutContinuation, +} + +impl VerifiedFederatedCodeExchange { + pub(crate) fn new( + identity: VerifiedFederatedIdentity, + redirect_after: Option, + logout_continuation: OpaqueLogoutContinuation, + ) -> Self { + Self { + identity, + redirect_after, + logout_continuation, + } + } + + pub(crate) fn into_parts(self) -> (VerifiedFederatedIdentity, Option, 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; + async fn verify_identity(&self, jwt: &str) -> Result; + async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result; +} + #[async_trait::async_trait] pub trait FederatedIdentityProvider: Send + Sync { fn has_providers(&self) -> bool; diff --git a/crates/iam/src/federation/registry.rs b/crates/iam/src/federation/registry.rs index 8c9f9c484..bce7c93bf 100644 --- a/crates/iam/src/federation/registry.rs +++ b/crates/iam/src/federation/registry.rs @@ -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, } @@ -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 { + Arc::clone(&self.standard_oidc) } } diff --git a/crates/iam/src/federation/transaction.rs b/crates/iam/src/federation/transaction.rs index 9b3047571..82e787ac3 100644 --- a/crates/iam/src/federation/transaction.rs +++ b/crates/iam/src/federation/transaction.rs @@ -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) -> Vec, + standard_oidc: Option, +} + +struct StandardOidcRuntime { + authentication: Arc, + mapper: CoreFederatedAuthorizationMapper, +} + +struct RuntimeCodeExchange { + authorization: FederatedAuthorization, + redirect_after: Option, + logout_continuation: OpaqueLogoutContinuation, } impl FederatedIdentityService { + async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result { + 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 { + 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 { + 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, + authentication: Arc, + 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 { - 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 { - 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 { - 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) -> Result { - 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 { - 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, binding: &dyn FederatedSessionBinding, ) -> Result { - 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> { - 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 { @@ -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 { + 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 { + 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 { + 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) -> 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 = provider.clone(); + let provider: Arc = provider; + FederatedIdentityService { + provider, + standard_oidc: Some(StandardOidcRuntime { + authentication, + mapper: CoreFederatedAuthorizationMapper::new(rules), + }), + } + } + struct RecordingBinding { fail: bool, events: Arc>>, transactions: Mutex)>>, + mapped_policies: Mutex>>, } 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())); diff --git a/crates/iam/src/lib.rs b/crates/iam/src/lib.rs index 8671155d3..baffd0a62 100644 --- a/crates/iam/src/lib.rs +++ b/crates/iam/src/lib.rs @@ -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; diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index 842a3caa5..dc58c4bd3 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -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, Vec) { - 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 { - 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 = 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 = 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> { + 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, 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, key: &str) -> Vec { 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, - 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) -> OidcSys { let configs = configs diff --git a/rustfs/src/startup_auth.rs b/rustfs/src/startup_auth.rs index 3674105f2..b3b10e4f8 100644 --- a/rustfs/src/startup_auth.rs +++ b/rustfs/src/startup_auth.rs @@ -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) -> (Arc, Arc) { 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 {