mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-11 07:36:53 +00:00
refactor(iam): introduce federated identity boundary (#5018)
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
// 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::{FederatedSessionBindingError, FederatedSessionTransaction};
|
||||
use rustfs_credentials::Credentials;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait FederatedSessionBinding: Send + Sync {
|
||||
async fn bind(
|
||||
&self,
|
||||
transaction: &FederatedSessionTransaction,
|
||||
) -> core::result::Result<Credentials, FederatedSessionBindingError>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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.
|
||||
|
||||
pub type Result<T> = core::result::Result<T, FederationError>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FederatedSessionBindingError {
|
||||
#[error("{0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("{0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FederationError {
|
||||
#[error("{0}")]
|
||||
Authorization(String),
|
||||
#[error("{0}")]
|
||||
CodeExchange(String),
|
||||
#[error("{0}")]
|
||||
TokenVerification(String),
|
||||
#[error("{0}")]
|
||||
Logout(String),
|
||||
#[error("no policies are available for this OIDC token")]
|
||||
NoAuthorizationContext,
|
||||
#[error(transparent)]
|
||||
Binding(#[from] FederatedSessionBindingError),
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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.
|
||||
|
||||
mod binding;
|
||||
mod error;
|
||||
mod model;
|
||||
pub mod oidc;
|
||||
mod provider;
|
||||
mod registry;
|
||||
mod transaction;
|
||||
|
||||
pub use binding::FederatedSessionBinding;
|
||||
pub use error::{FederatedSessionBindingError, FederationError, Result};
|
||||
pub use model::{
|
||||
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedLoginSession, FederatedSession,
|
||||
FederatedSessionTransaction,
|
||||
};
|
||||
pub use provider::FederatedIdentityProvider;
|
||||
pub use registry::FederatedIdentityRegistry;
|
||||
pub use transaction::FederatedIdentityService;
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_credentials::Credentials;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FederatedClaims {
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub groups: Vec<String>,
|
||||
pub raw: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl FederatedClaims {
|
||||
pub fn session_identity(&self) -> String {
|
||||
if !self.username.is_empty() {
|
||||
self.username.clone()
|
||||
} else if !self.email.is_empty() {
|
||||
self.email.clone()
|
||||
} else if !self.sub.is_empty() {
|
||||
self.sub.clone()
|
||||
} else {
|
||||
"oidc-user-unknown".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FederatedAuthorization {
|
||||
pub provider_id: String,
|
||||
pub claims: FederatedClaims,
|
||||
pub policies: Vec<String>,
|
||||
pub groups: Vec<String>,
|
||||
pub roles_claim_key: Option<String>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
impl FederatedAuthorization {
|
||||
pub fn has_authorization_context(&self) -> bool {
|
||||
!self.policies.is_empty() || !self.groups.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FederatedCodeExchange {
|
||||
pub authorization: FederatedAuthorization,
|
||||
pub redirect_after: Option<String>,
|
||||
pub id_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FederatedSessionTransaction {
|
||||
pub authorization: FederatedAuthorization,
|
||||
pub duration_seconds: usize,
|
||||
pub session_policy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FederatedSession {
|
||||
pub credentials: Credentials,
|
||||
pub authorization: FederatedAuthorization,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FederatedLoginSession {
|
||||
pub session: FederatedSession,
|
||||
pub redirect_after: Option<String>,
|
||||
pub logout_token: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn claims(username: &str, email: &str, sub: &str) -> FederatedClaims {
|
||||
FederatedClaims {
|
||||
sub: sub.to_string(),
|
||||
email: email.to_string(),
|
||||
username: username.to_string(),
|
||||
groups: Vec::new(),
|
||||
raw: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn authorization(policies: Vec<String>, groups: Vec<String>) -> FederatedAuthorization {
|
||||
FederatedAuthorization {
|
||||
provider_id: "standard_oidc".to_string(),
|
||||
claims: claims("", "", "subject"),
|
||||
policies,
|
||||
groups,
|
||||
roles_claim_key: None,
|
||||
roles: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_identity_preserves_existing_fallback_order() {
|
||||
assert_eq!(claims("john", "john@example.com", "sub-1").session_identity(), "john");
|
||||
assert_eq!(claims("", "john@example.com", "sub-1").session_identity(), "john@example.com");
|
||||
assert_eq!(claims("", "", "sub-1").session_identity(), "sub-1");
|
||||
assert_eq!(claims("", "", "").session_identity(), "oidc-user-unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_context_accepts_policy_or_group() {
|
||||
assert!(!authorization(Vec::new(), Vec::new()).has_authorization_context());
|
||||
assert!(authorization(vec!["consoleAdmin".to_string()], Vec::new()).has_authorization_context());
|
||||
assert!(authorization(Vec::new(), vec!["RustFS.ConsoleAdmin".to_string()]).has_authorization_context());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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::config::roles_claim_key;
|
||||
use crate::{
|
||||
federation::{FederatedAuthorization, FederatedClaims},
|
||||
oidc::{OidcClaims, OidcSys},
|
||||
};
|
||||
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 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 {
|
||||
sub: claims.sub,
|
||||
email: claims.email,
|
||||
username: claims.username,
|
||||
groups: claims.groups,
|
||||
raw: claims.raw,
|
||||
},
|
||||
policies,
|
||||
groups,
|
||||
roles_claim_key,
|
||||
roles,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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 crate::oidc::{OidcProviderConfig, OidcSys};
|
||||
|
||||
pub(super) fn provider_config<'a>(oidc: &'a OidcSys, id: &str) -> Option<&'a OidcProviderConfig> {
|
||||
oidc.get_provider_config(id)
|
||||
}
|
||||
|
||||
pub(super) fn roles_claim_key(oidc: &OidcSys, provider_id: &str) -> Option<String> {
|
||||
provider_config(oidc, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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 crate::oidc::{OidcProviderSummary, OidcSys};
|
||||
|
||||
pub(super) fn list_providers(oidc: &OidcSys) -> Vec<OidcProviderSummary> {
|
||||
oidc.list_providers()
|
||||
}
|
||||
|
||||
pub(super) fn list_visible_providers(oidc: &OidcSys) -> Vec<OidcProviderSummary> {
|
||||
oidc.list_visible_providers()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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::{claims, config, discovery, http};
|
||||
use crate::{
|
||||
federation::{FederatedAuthorization, FederatedCodeExchange, FederatedIdentityProvider, FederationError, Result},
|
||||
oidc::{OidcProviderConfig, OidcProviderSummary, OidcSys},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct StandardOidcAdapter {
|
||||
oidc: Arc<OidcSys>,
|
||||
}
|
||||
|
||||
impl StandardOidcAdapter {
|
||||
pub fn new(oidc: Arc<OidcSys>) -> Self {
|
||||
Self { oidc }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FederatedIdentityProvider for StandardOidcAdapter {
|
||||
fn has_providers(&self) -> bool {
|
||||
self.oidc.has_providers()
|
||||
}
|
||||
|
||||
fn list_providers(&self) -> Vec<OidcProviderSummary> {
|
||||
discovery::list_providers(&self.oidc)
|
||||
}
|
||||
|
||||
fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
|
||||
discovery::list_visible_providers(&self.oidc)
|
||||
}
|
||||
|
||||
fn provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
|
||||
config::provider_config(&self.oidc, id)
|
||||
}
|
||||
|
||||
async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String> {
|
||||
http::authorize_url(&self.oidc, provider_id, redirect_uri, redirect_after).await
|
||||
}
|
||||
|
||||
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?;
|
||||
Ok(FederatedCodeExchange {
|
||||
authorization: claims::authorization(&self.oidc, provider_id, oidc_claims),
|
||||
redirect_after: session.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))
|
||||
}
|
||||
|
||||
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String> {
|
||||
http::create_logout_token(&self.oidc, provider_id, id_token).await
|
||||
}
|
||||
|
||||
async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>> {
|
||||
http::build_logout_url(&self.oidc, logout_token, post_logout_redirect_uri).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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 crate::{
|
||||
federation::{FederationError, Result},
|
||||
oidc::{OidcClaims, OidcSys},
|
||||
oidc_state::OidcAuthSession,
|
||||
};
|
||||
|
||||
pub(super) async fn authorize_url(
|
||||
oidc: &OidcSys,
|
||||
provider_id: &str,
|
||||
redirect_uri: &str,
|
||||
redirect_after: Option<String>,
|
||||
) -> Result<String> {
|
||||
oidc.authorize_url(provider_id, redirect_uri, redirect_after)
|
||||
.await
|
||||
.map_err(FederationError::Authorization)
|
||||
}
|
||||
|
||||
pub(super) async fn exchange_code(
|
||||
oidc: &OidcSys,
|
||||
state: &str,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(OidcClaims, String, OidcAuthSession, String)> {
|
||||
oidc.exchange_code(state, code, redirect_uri)
|
||||
.await
|
||||
.map_err(FederationError::CodeExchange)
|
||||
}
|
||||
|
||||
pub(super) async fn create_logout_token(oidc: &OidcSys, provider_id: &str, id_token: &str) -> Result<String> {
|
||||
oidc.create_logout_token(provider_id, id_token)
|
||||
.await
|
||||
.map_err(FederationError::Logout)
|
||||
}
|
||||
|
||||
pub(super) async fn build_logout_url(
|
||||
oidc: &OidcSys,
|
||||
logout_token: &str,
|
||||
post_logout_redirect_uri: &str,
|
||||
) -> Result<Option<String>> {
|
||||
oidc.build_logout_url(logout_token, post_logout_redirect_uri)
|
||||
.await
|
||||
.map_err(FederationError::Logout)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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.
|
||||
|
||||
mod claims;
|
||||
mod config;
|
||||
mod discovery;
|
||||
mod flow;
|
||||
mod http;
|
||||
|
||||
pub use flow::StandardOidcAdapter;
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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, FederatedCodeExchange, Result};
|
||||
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait FederatedIdentityProvider: Send + Sync {
|
||||
fn has_providers(&self) -> bool;
|
||||
|
||||
fn list_providers(&self) -> Vec<OidcProviderSummary>;
|
||||
|
||||
fn list_visible_providers(&self) -> Vec<OidcProviderSummary>;
|
||||
|
||||
fn provider_config(&self, id: &str) -> Option<&OidcProviderConfig>;
|
||||
|
||||
async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String>;
|
||||
|
||||
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<FederatedCodeExchange>;
|
||||
|
||||
async fn verify_web_identity_token(&self, jwt: &str) -> Result<FederatedAuthorization>;
|
||||
|
||||
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String>;
|
||||
|
||||
async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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::FederatedIdentityProvider;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Immutable registry of built-in federation adapters.
|
||||
pub struct FederatedIdentityRegistry {
|
||||
standard_oidc: Arc<dyn FederatedIdentityProvider>,
|
||||
}
|
||||
|
||||
impl FederatedIdentityRegistry {
|
||||
pub fn new(standard_oidc: Arc<dyn FederatedIdentityProvider>) -> Self {
|
||||
Self { standard_oidc }
|
||||
}
|
||||
|
||||
pub(crate) fn standard_oidc(&self) -> &dyn FederatedIdentityProvider {
|
||||
self.standard_oidc.as_ref()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// 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::{
|
||||
FederatedIdentityRegistry, FederatedLoginSession, FederatedSession, FederatedSessionBinding, FederatedSessionTransaction,
|
||||
FederationError, Result,
|
||||
};
|
||||
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
|
||||
|
||||
pub struct FederatedIdentityService {
|
||||
registry: FederatedIdentityRegistry,
|
||||
}
|
||||
|
||||
impl FederatedIdentityService {
|
||||
pub fn new(registry: FederatedIdentityRegistry) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
|
||||
pub fn has_providers(&self) -> bool {
|
||||
self.registry.standard_oidc().has_providers()
|
||||
}
|
||||
|
||||
pub fn list_providers(&self) -> Vec<OidcProviderSummary> {
|
||||
self.registry.standard_oidc().list_providers()
|
||||
}
|
||||
|
||||
pub fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
|
||||
self.registry.standard_oidc().list_visible_providers()
|
||||
}
|
||||
|
||||
pub fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
|
||||
self.registry.standard_oidc().provider_config(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
|
||||
}
|
||||
|
||||
pub async fn complete_authorization_code(
|
||||
&self,
|
||||
state: &str,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
duration_seconds: usize,
|
||||
binding: &dyn FederatedSessionBinding,
|
||||
) -> Result<FederatedLoginSession> {
|
||||
let exchange = self.registry.standard_oidc().exchange_code(state, code, redirect_uri).await?;
|
||||
let provider_id = exchange.authorization.provider_id.clone();
|
||||
let transaction = FederatedSessionTransaction {
|
||||
authorization: exchange.authorization,
|
||||
duration_seconds,
|
||||
session_policy: None,
|
||||
};
|
||||
let credentials = binding.bind(&transaction).await?;
|
||||
let logout_token = self
|
||||
.registry
|
||||
.standard_oidc()
|
||||
.create_logout_token(&provider_id, &exchange.id_token)
|
||||
.await?;
|
||||
|
||||
Ok(FederatedLoginSession {
|
||||
session: FederatedSession {
|
||||
credentials,
|
||||
authorization: transaction.authorization,
|
||||
},
|
||||
redirect_after: exchange.redirect_after,
|
||||
logout_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn assume_role_with_web_identity(
|
||||
&self,
|
||||
jwt: &str,
|
||||
duration_seconds: usize,
|
||||
session_policy: Option<String>,
|
||||
binding: &dyn FederatedSessionBinding,
|
||||
) -> Result<FederatedSession> {
|
||||
let authorization = self.registry.standard_oidc().verify_web_identity_token(jwt).await?;
|
||||
if !authorization.has_authorization_context() {
|
||||
tracing::warn!(
|
||||
provider_id = %authorization.provider_id,
|
||||
username = %authorization.claims.username,
|
||||
sub = %authorization.claims.sub,
|
||||
policy_count = authorization.policies.len(),
|
||||
group_count = authorization.groups.len(),
|
||||
"AssumeRoleWithWebIdentity has no mapped policies or groups"
|
||||
);
|
||||
return Err(FederationError::NoAuthorizationContext);
|
||||
}
|
||||
tracing::debug!(
|
||||
provider_id = %authorization.provider_id,
|
||||
username = %authorization.claims.username,
|
||||
policy_count = authorization.policies.len(),
|
||||
group_count = authorization.groups.len(),
|
||||
policies = ?authorization.policies,
|
||||
groups = ?authorization.groups,
|
||||
"AssumeRoleWithWebIdentity mapped OIDC policies and groups"
|
||||
);
|
||||
|
||||
let transaction = FederatedSessionTransaction {
|
||||
authorization,
|
||||
duration_seconds,
|
||||
session_policy,
|
||||
};
|
||||
let credentials = binding.bind(&transaction).await?;
|
||||
|
||||
Ok(FederatedSession {
|
||||
credentials,
|
||||
authorization: transaction.authorization,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::federation::{
|
||||
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedIdentityProvider, FederatedSessionBindingError,
|
||||
};
|
||||
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
|
||||
use rustfs_credentials::Credentials;
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct TestProvider {
|
||||
with_policy: bool,
|
||||
events: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
impl TestProvider {
|
||||
fn record(&self, event: &'static str) {
|
||||
self.events.lock().expect("event log should not be poisoned").push(event);
|
||||
}
|
||||
|
||||
fn authorization(&self) -> FederatedAuthorization {
|
||||
FederatedAuthorization {
|
||||
provider_id: "default".to_string(),
|
||||
claims: FederatedClaims {
|
||||
sub: "subject".to_string(),
|
||||
email: String::new(),
|
||||
username: "user".to_string(),
|
||||
groups: Vec::new(),
|
||||
raw: Default::default(),
|
||||
},
|
||||
policies: if self.with_policy {
|
||||
vec!["readwrite".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
groups: Vec::new(),
|
||||
roles_claim_key: None,
|
||||
roles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FederatedIdentityProvider for TestProvider {
|
||||
fn has_providers(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn list_providers(&self) -> Vec<OidcProviderSummary> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn provider_config(&self, _id: &str) -> Option<&OidcProviderConfig> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn authorize_url(
|
||||
&self,
|
||||
_provider_id: &str,
|
||||
_redirect_uri: &str,
|
||||
_redirect_after: Option<String>,
|
||||
) -> Result<String> {
|
||||
Ok("https://identity.example/authorize".to_string())
|
||||
}
|
||||
|
||||
async fn exchange_code(&self, _state: &str, _code: &str, _redirect_uri: &str) -> Result<FederatedCodeExchange> {
|
||||
self.record("exchange");
|
||||
Ok(FederatedCodeExchange {
|
||||
authorization: self.authorization(),
|
||||
redirect_after: Some("/browser".to_string()),
|
||||
id_token: "id-token".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify_web_identity_token(&self, _jwt: &str) -> Result<FederatedAuthorization> {
|
||||
self.record("verify");
|
||||
Ok(self.authorization())
|
||||
}
|
||||
|
||||
async fn create_logout_token(&self, _provider_id: &str, _id_token: &str) -> Result<String> {
|
||||
self.record("logout");
|
||||
Ok("logout-token".to_string())
|
||||
}
|
||||
|
||||
async fn build_logout_url(&self, _logout_token: &str, _post_logout_redirect_uri: &str) -> Result<Option<String>> {
|
||||
Ok(Some("https://identity.example/logout".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
struct CountingBinding {
|
||||
calls: AtomicUsize,
|
||||
events: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FederatedSessionBinding for CountingBinding {
|
||||
async fn bind(
|
||||
&self,
|
||||
transaction: &FederatedSessionTransaction,
|
||||
) -> core::result::Result<Credentials, FederatedSessionBindingError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.events.lock().expect("event log should not be poisoned").push("bind");
|
||||
Ok(Credentials {
|
||||
access_key: transaction.authorization.claims.session_identity(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_and_web_identity_share_session_binding() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let provider = Arc::new(TestProvider {
|
||||
with_policy: true,
|
||||
events: events.clone(),
|
||||
});
|
||||
let binding = Arc::new(CountingBinding {
|
||||
calls: AtomicUsize::new(0),
|
||||
events: events.clone(),
|
||||
});
|
||||
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider));
|
||||
|
||||
let login = service
|
||||
.complete_authorization_code("state", "code", "https://console.example/callback", 3600, binding.as_ref())
|
||||
.await
|
||||
.expect("callback flow should complete");
|
||||
assert_eq!(login.session.credentials.access_key, "user");
|
||||
assert_eq!(login.redirect_after.as_deref(), Some("/browser"));
|
||||
assert_eq!(login.logout_token, "logout-token");
|
||||
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", 3600, None, binding.as_ref())
|
||||
.await
|
||||
.expect("web identity flow should complete");
|
||||
assert_eq!(web_identity.credentials.access_key, "user");
|
||||
assert_eq!(binding.calls.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify", "bind"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_identity_without_policy_or_group_is_not_bound() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let provider = Arc::new(TestProvider {
|
||||
with_policy: false,
|
||||
events: events.clone(),
|
||||
});
|
||||
let binding = Arc::new(CountingBinding {
|
||||
calls: AtomicUsize::new(0),
|
||||
events,
|
||||
});
|
||||
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider));
|
||||
|
||||
let error = service
|
||||
.assume_role_with_web_identity("jwt", 3600, None, binding.as_ref())
|
||||
.await
|
||||
.expect_err("authorization context is required");
|
||||
|
||||
assert!(matches!(error, FederationError::NoAuthorizationContext));
|
||||
assert_eq!(binding.calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const EVENT_OIDC_STATE: &str = "oidc_state";
|
||||
|
||||
pub mod cache;
|
||||
pub mod error;
|
||||
pub mod federation;
|
||||
pub mod keyring;
|
||||
pub mod manager;
|
||||
pub mod oidc;
|
||||
|
||||
Reference in New Issue
Block a user