mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 16:28:15 +00:00
feat(admin): add persisted OIDC config APIs (#2267)
Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
@@ -12,13 +12,14 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::config::{Config, GLOBAL_STORAGE_CLASS, storageclass};
|
||||
use crate::config::{Config, GLOBAL_STORAGE_CLASS, KVS, oidc, storageclass};
|
||||
use crate::disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::global::is_first_cluster_node_local;
|
||||
use crate::store_api::{ObjectInfo, ObjectOptions, PutObjReader, StorageAPI};
|
||||
use http::HeaderMap;
|
||||
use rustfs_config::{DEFAULT_DELIMITER, RUSTFS_REGION};
|
||||
use rustfs_config::oidc::{IDENTITY_OPENID_KEYS, IDENTITY_OPENID_SUB_SYS, OIDC_REDIRECT_URI_DYNAMIC};
|
||||
use rustfs_config::{COMMENT_KEY, DEFAULT_DELIMITER, ENABLE_KEY, EnableState, RUSTFS_REGION};
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -181,6 +182,85 @@ fn parse_inline_block_value(value: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_oidc_scalar_value(key: &str, value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(v) => Some(v.trim().to_string()),
|
||||
Value::Bool(v) if key == ENABLE_KEY || key == OIDC_REDIRECT_URI_DYNAMIC => Some(if *v {
|
||||
EnableState::On.to_string()
|
||||
} else {
|
||||
EnableState::Off.to_string()
|
||||
}),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
Value::Array(values) if key == rustfs_config::oidc::OIDC_SCOPES => {
|
||||
let scopes = values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
Some(scopes)
|
||||
}
|
||||
Value::Null => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_oidc_provider_object(provider: &Map<String, Value>) -> KVS {
|
||||
let mut kvs = oidc::DEFAULT_IDENTITY_OPENID_KVS.clone();
|
||||
|
||||
for (key, value) in provider {
|
||||
if !IDENTITY_OPENID_KEYS.contains(&key.as_str()) || key == COMMENT_KEY {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parsed) = parse_oidc_scalar_value(key, value) {
|
||||
kvs.insert(key.clone(), parsed);
|
||||
}
|
||||
}
|
||||
|
||||
kvs
|
||||
}
|
||||
|
||||
fn apply_external_oidc_map(cfg: &mut Config, root: &Map<String, Value>) -> bool {
|
||||
let oidc_root = root.get("openid").or_else(|| root.get(IDENTITY_OPENID_SUB_SYS));
|
||||
let Some(Value::Object(oidc_obj)) = oidc_root else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if oidc_obj.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let subsystem = cfg.0.entry(IDENTITY_OPENID_SUB_SYS.to_string()).or_default();
|
||||
let mut applied = false;
|
||||
|
||||
for (raw_instance, provider) in oidc_obj {
|
||||
let instance_key = if raw_instance == "default" {
|
||||
DEFAULT_DELIMITER.to_string()
|
||||
} else {
|
||||
raw_instance.to_string()
|
||||
};
|
||||
|
||||
match provider {
|
||||
Value::Object(provider_obj) => {
|
||||
subsystem.insert(instance_key, decode_oidc_provider_object(provider_obj));
|
||||
applied = true;
|
||||
}
|
||||
Value::Array(_) => {
|
||||
if let Ok(kvs) = serde_json::from_value::<KVS>(provider.clone()) {
|
||||
subsystem.insert(instance_key, kvs);
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
applied
|
||||
}
|
||||
|
||||
fn apply_external_storage_class_map(cfg: &mut Config, root: &Map<String, Value>) -> bool {
|
||||
let sc = root.get("storageclass").or_else(|| root.get("storage_class"));
|
||||
let Some(Value::Object(sc_obj)) = sc else {
|
||||
@@ -224,8 +304,9 @@ fn decode_server_config_blob(data: &[u8]) -> Result<Config> {
|
||||
|
||||
let mut cfg = Config::new();
|
||||
let has_storage = apply_external_storage_class_map(&mut cfg, &root);
|
||||
let has_oidc = apply_external_oidc_map(&mut cfg, &root);
|
||||
let has_header = root.contains_key("version") || root.contains_key("region") || root.contains_key("credential");
|
||||
if !has_storage && !has_header {
|
||||
if !has_storage && !has_oidc && !has_header {
|
||||
return Err(Error::other("unrecognized external server config shape"));
|
||||
}
|
||||
Ok(cfg)
|
||||
@@ -255,6 +336,119 @@ fn build_storageclass_object(cfg: &Config) -> Map<String, Value> {
|
||||
sc_obj
|
||||
}
|
||||
|
||||
fn build_oidc_provider_object(kvs: &KVS) -> Map<String, Value> {
|
||||
let mut provider = Map::new();
|
||||
|
||||
for kv in &kvs.0 {
|
||||
if kv.key == COMMENT_KEY || (kv.hidden_if_empty && kv.value.trim().is_empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if kv.value.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if kv.key == ENABLE_KEY || kv.key == OIDC_REDIRECT_URI_DYNAMIC {
|
||||
let enabled = kv
|
||||
.value
|
||||
.parse::<EnableState>()
|
||||
.map(|state| state.is_enabled())
|
||||
.unwrap_or(false);
|
||||
provider.insert(kv.key.clone(), Value::Bool(enabled));
|
||||
continue;
|
||||
}
|
||||
|
||||
if kv.key == rustfs_config::oidc::OIDC_SCOPES {
|
||||
let scopes = kv
|
||||
.value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.map(|scope| Value::String(scope.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
provider.insert(kv.key.clone(), Value::Array(scopes));
|
||||
continue;
|
||||
}
|
||||
|
||||
provider.insert(kv.key.clone(), Value::String(kv.value.clone()));
|
||||
}
|
||||
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_oidc_object(cfg: &Config) -> Map<String, Value> {
|
||||
let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else {
|
||||
return Map::new();
|
||||
};
|
||||
|
||||
let mut providers = subsystem.iter().collect::<Vec<_>>();
|
||||
providers.sort_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
|
||||
|
||||
let mut oidc_obj = Map::new();
|
||||
for (instance_key, kvs) in providers {
|
||||
if kvs
|
||||
.lookup(rustfs_config::oidc::OIDC_CONFIG_URL)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let provider = build_oidc_provider_object(kvs);
|
||||
if provider.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let external_key = if instance_key == DEFAULT_DELIMITER {
|
||||
"default".to_string()
|
||||
} else {
|
||||
instance_key.clone()
|
||||
};
|
||||
oidc_obj.insert(external_key, Value::Object(provider));
|
||||
}
|
||||
|
||||
oidc_obj
|
||||
}
|
||||
|
||||
fn build_semantic_oidc_object(cfg: &Config) -> Map<String, Value> {
|
||||
let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else {
|
||||
return Map::new();
|
||||
};
|
||||
|
||||
let mut providers = subsystem.iter().collect::<Vec<_>>();
|
||||
providers.sort_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
|
||||
|
||||
let mut oidc_obj = Map::new();
|
||||
for (instance_key, kvs) in providers {
|
||||
let mut normalized = oidc::DEFAULT_IDENTITY_OPENID_KVS.clone();
|
||||
normalized.extend(kvs.clone());
|
||||
|
||||
if normalized
|
||||
.lookup(rustfs_config::oidc::OIDC_CONFIG_URL)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let provider = build_oidc_provider_object(&normalized);
|
||||
if provider.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let external_key = if instance_key == DEFAULT_DELIMITER {
|
||||
"default".to_string()
|
||||
} else {
|
||||
instance_key.clone()
|
||||
};
|
||||
oidc_obj.insert(external_key, Value::Object(provider));
|
||||
}
|
||||
|
||||
oidc_obj
|
||||
}
|
||||
|
||||
fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||
let mut root = seed.and_then(parse_object_seed).unwrap_or_default();
|
||||
|
||||
@@ -275,6 +469,15 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
|
||||
root.insert("storageclass".to_string(), Value::Object(sc_obj));
|
||||
root.remove("storage_class");
|
||||
|
||||
let oidc_obj = build_oidc_object(cfg);
|
||||
if oidc_obj.is_empty() {
|
||||
root.remove("openid");
|
||||
root.remove(IDENTITY_OPENID_SUB_SYS);
|
||||
} else {
|
||||
root.insert("openid".to_string(), Value::Object(oidc_obj));
|
||||
root.remove(IDENTITY_OPENID_SUB_SYS);
|
||||
}
|
||||
|
||||
Ok(serde_json::to_vec(&Value::Object(root))?)
|
||||
}
|
||||
|
||||
@@ -292,6 +495,7 @@ fn is_standard_object_server_config(data: &[u8]) -> bool {
|
||||
|
||||
fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool {
|
||||
build_storageclass_object(lhs) == build_storageclass_object(rhs)
|
||||
&& build_semantic_oidc_object(lhs) == build_semantic_oidc_object(rhs)
|
||||
}
|
||||
|
||||
fn is_object_not_found(err: &Error) -> bool {
|
||||
@@ -508,7 +712,9 @@ mod tests {
|
||||
configs_semantically_equal, decode_server_config_blob, encode_server_config_blob, is_standard_object_server_config,
|
||||
storage_class_kvs_mut,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, oidc};
|
||||
use rustfs_config::oidc::IDENTITY_OPENID_SUB_SYS;
|
||||
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState};
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
@@ -550,6 +756,54 @@ mod tests {
|
||||
assert_eq!(kvs.get("optimize"), "availability");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_server_config_reads_openid_providers() {
|
||||
let input = r#"{
|
||||
"version":"33",
|
||||
"storageclass":{"standard":"EC:2","rrs":"EC:1"},
|
||||
"openid":{
|
||||
"default":{
|
||||
"enable":true,
|
||||
"config_url":"https://example.com/.well-known/openid-configuration",
|
||||
"client_id":"console",
|
||||
"client_secret":"secret-value",
|
||||
"scopes":["openid","profile","email"],
|
||||
"redirect_uri_dynamic":true,
|
||||
"display_name":"Default Provider"
|
||||
},
|
||||
"smoke":{
|
||||
"enable":false,
|
||||
"config_url":"https://issuer.example.com/.well-known/openid-configuration",
|
||||
"client_id":"smoke-client",
|
||||
"scopes":["openid"],
|
||||
"redirect_uri_dynamic":false
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let cfg = decode_server_config_blob(input.as_bytes()).expect("decode should succeed");
|
||||
|
||||
let default_kvs = cfg
|
||||
.get_value(IDENTITY_OPENID_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.expect("default oidc provider should exist");
|
||||
assert_eq!(
|
||||
default_kvs.get(rustfs_config::oidc::OIDC_CONFIG_URL),
|
||||
"https://example.com/.well-known/openid-configuration"
|
||||
);
|
||||
assert_eq!(default_kvs.get(rustfs_config::oidc::OIDC_CLIENT_ID), "console");
|
||||
assert_eq!(default_kvs.get(rustfs_config::oidc::OIDC_SCOPES), "openid,profile,email");
|
||||
assert_eq!(default_kvs.get(ENABLE_KEY), EnableState::On.to_string());
|
||||
|
||||
let smoke_kvs = cfg
|
||||
.get_value(IDENTITY_OPENID_SUB_SYS, "smoke")
|
||||
.expect("named oidc provider should exist");
|
||||
assert_eq!(smoke_kvs.get(rustfs_config::oidc::OIDC_CLIENT_ID), "smoke-client");
|
||||
assert_eq!(
|
||||
smoke_kvs.get(rustfs_config::oidc::OIDC_REDIRECT_URI_DYNAMIC),
|
||||
EnableState::Off.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_server_config_writes_external_object_shape() {
|
||||
let mut cfg = Config::new();
|
||||
@@ -564,6 +818,48 @@ mod tests {
|
||||
assert!(v.get("storage_class").is_none(), "should not write rustfs map shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_server_config_writes_openid_object_shape() {
|
||||
let mut cfg = Config::new();
|
||||
let mut oidc_section = std::collections::HashMap::new();
|
||||
let mut default_provider = oidc::DEFAULT_IDENTITY_OPENID_KVS.clone();
|
||||
default_provider.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
|
||||
default_provider.insert(
|
||||
rustfs_config::oidc::OIDC_CONFIG_URL.to_string(),
|
||||
"https://example.com/.well-known/openid-configuration".to_string(),
|
||||
);
|
||||
default_provider.insert(rustfs_config::oidc::OIDC_CLIENT_ID.to_string(), "console".to_string());
|
||||
default_provider.insert(rustfs_config::oidc::OIDC_SCOPES.to_string(), "openid,profile,email".to_string());
|
||||
oidc_section.insert(DEFAULT_DELIMITER.to_string(), default_provider);
|
||||
cfg.0.insert(IDENTITY_OPENID_SUB_SYS.to_string(), oidc_section);
|
||||
|
||||
let out = encode_server_config_blob(&cfg, None).expect("encode should succeed");
|
||||
let v: Value = serde_json::from_slice(&out).expect("output should be json");
|
||||
let openid = v
|
||||
.get("openid")
|
||||
.and_then(Value::as_object)
|
||||
.expect("output should include openid object");
|
||||
let default_provider = openid
|
||||
.get("default")
|
||||
.and_then(Value::as_object)
|
||||
.expect("default provider should be encoded");
|
||||
|
||||
assert_eq!(
|
||||
default_provider
|
||||
.get(rustfs_config::oidc::OIDC_CLIENT_ID)
|
||||
.and_then(Value::as_str),
|
||||
Some("console")
|
||||
);
|
||||
assert_eq!(
|
||||
default_provider
|
||||
.get(rustfs_config::oidc::OIDC_SCOPES)
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
|
||||
Some(vec!["openid", "profile", "email"])
|
||||
);
|
||||
assert_eq!(default_provider.get(ENABLE_KEY).and_then(Value::as_bool), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_standard_object_server_config_detection() {
|
||||
let external = br#"{"version":"33","storageclass":{"standard":"EC:2","rrs":"EC:1"}}"#;
|
||||
@@ -581,4 +877,39 @@ mod tests {
|
||||
let rhs = decode_server_config_blob(legacy).expect("decode legacy");
|
||||
assert!(configs_semantically_equal(&lhs, &rhs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configs_semantically_equal_accounts_for_openid() {
|
||||
let external = br#"{
|
||||
"version":"33",
|
||||
"storageclass":{"standard":"EC:2","rrs":"EC:1","optimize":"availability"},
|
||||
"openid":{
|
||||
"default":{
|
||||
"enable":true,
|
||||
"config_url":"https://example.com/.well-known/openid-configuration",
|
||||
"client_id":"console",
|
||||
"scopes":["openid","profile","email"],
|
||||
"redirect_uri_dynamic":true
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let legacy = br#"{
|
||||
"storage_class":{"_":[
|
||||
{"key":"standard","value":"EC:2"},
|
||||
{"key":"rrs","value":"EC:1"},
|
||||
{"key":"optimize","value":"availability"}
|
||||
]},
|
||||
"identity_openid":{"_":[
|
||||
{"key":"enable","value":"on"},
|
||||
{"key":"config_url","value":"https://example.com/.well-known/openid-configuration"},
|
||||
{"key":"client_id","value":"console"},
|
||||
{"key":"scopes","value":"openid,profile,email"},
|
||||
{"key":"redirect_uri_dynamic","value":"on"}
|
||||
]}
|
||||
}"#;
|
||||
|
||||
let lhs = decode_server_config_blob(external).expect("decode external");
|
||||
let rhs = decode_server_config_blob(legacy).expect("decode legacy");
|
||||
assert!(configs_semantically_equal(&lhs, &rhs));
|
||||
}
|
||||
}
|
||||
|
||||
+226
-3
@@ -25,6 +25,8 @@ use openidconnect::{
|
||||
PkceCodeVerifier, RedirectUrl, Scope,
|
||||
};
|
||||
use rustfs_config::oidc::*;
|
||||
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState};
|
||||
use rustfs_ecstore::config::{Config as ServerConfig, KVS, get_global_server_config};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
@@ -101,7 +103,7 @@ impl<'c> AsyncHttpClient<'c> for ReqwestHttpClient {
|
||||
// ---- Public types (unchanged API) ----
|
||||
|
||||
/// Parsed configuration for a single OIDC provider.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OidcProviderConfig {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
@@ -120,6 +122,26 @@ pub struct OidcProviderConfig {
|
||||
pub username_claim: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OidcProviderConfigSource {
|
||||
Env,
|
||||
Persisted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SourcedOidcProviderConfig {
|
||||
pub config: OidcProviderConfig,
|
||||
pub source: OidcProviderConfigSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OidcProviderValidationResult {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// Summary info about a provider, returned to the console.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcProviderSummary {
|
||||
@@ -170,11 +192,12 @@ impl OidcSys {
|
||||
/// Parse environment variables and discover all configured OIDC providers.
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
let http_client = ReqwestHttpClient(reqwest::Client::new());
|
||||
let parsed_configs = Self::parse_env_configs();
|
||||
let parsed_configs = load_effective_oidc_provider_configs(get_global_server_config().as_ref());
|
||||
let mut configs = HashMap::new();
|
||||
let mut provider_states = HashMap::new();
|
||||
|
||||
for config in parsed_configs {
|
||||
for sourced_config in parsed_configs {
|
||||
let config = sourced_config.config;
|
||||
if !config.enabled {
|
||||
info!("OIDC provider '{}' is disabled, skipping", config.id);
|
||||
continue;
|
||||
@@ -620,6 +643,33 @@ impl OidcSys {
|
||||
configs
|
||||
}
|
||||
|
||||
fn parse_persisted_configs(cfg: &ServerConfig) -> Vec<OidcProviderConfig> {
|
||||
let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut configs = Vec::new();
|
||||
let mut provider_ids: Vec<String> = subsystem.keys().cloned().collect();
|
||||
provider_ids.sort();
|
||||
|
||||
for raw_id in provider_ids {
|
||||
let Some(kvs) = subsystem.get(&raw_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let id = if raw_id == DEFAULT_DELIMITER {
|
||||
"default"
|
||||
} else {
|
||||
raw_id.as_str()
|
||||
};
|
||||
if let Some(config) = Self::parse_single_persisted_provider(kvs, id) {
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
configs
|
||||
}
|
||||
|
||||
/// Parse a single provider's config from env vars with the given suffix.
|
||||
fn parse_single_provider(env_suffix: &str, id: &str) -> Option<OidcProviderConfig> {
|
||||
let get_env = |base: &str| -> String { std::env::var(format!("{base}{env_suffix}")).unwrap_or_default() };
|
||||
@@ -716,6 +766,68 @@ impl OidcSys {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_single_persisted_provider(kvs: &KVS, id: &str) -> Option<OidcProviderConfig> {
|
||||
let config_url = kvs.get(OIDC_CONFIG_URL);
|
||||
if config_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let enabled = kvs
|
||||
.lookup(ENABLE_KEY)
|
||||
.unwrap_or_else(|| EnableState::Off.to_string())
|
||||
.parse::<EnableState>()
|
||||
.map(|s| s.is_enabled())
|
||||
.unwrap_or(false);
|
||||
|
||||
let scopes_str = kvs.get(OIDC_SCOPES);
|
||||
let scopes = if scopes_str.is_empty() {
|
||||
OIDC_DEFAULT_SCOPES.split(',').map(String::from).collect()
|
||||
} else {
|
||||
scopes_str.split(',').map(|s| s.trim().to_string()).collect()
|
||||
};
|
||||
|
||||
let redirect_uri_dynamic = kvs
|
||||
.lookup(OIDC_REDIRECT_URI_DYNAMIC)
|
||||
.unwrap_or_else(|| EnableState::On.to_string())
|
||||
.parse::<EnableState>()
|
||||
.map(|s| s.is_enabled())
|
||||
.unwrap_or(true);
|
||||
|
||||
let claim_name = kvs
|
||||
.lookup(OIDC_CLAIM_NAME)
|
||||
.unwrap_or_else(|| OIDC_DEFAULT_CLAIM_NAME.to_string());
|
||||
let groups_claim = kvs
|
||||
.lookup(OIDC_GROUPS_CLAIM)
|
||||
.unwrap_or_else(|| OIDC_DEFAULT_GROUPS_CLAIM.to_string());
|
||||
let email_claim = kvs
|
||||
.lookup(OIDC_EMAIL_CLAIM)
|
||||
.unwrap_or_else(|| OIDC_DEFAULT_EMAIL_CLAIM.to_string());
|
||||
let username_claim = kvs
|
||||
.lookup(OIDC_USERNAME_CLAIM)
|
||||
.unwrap_or_else(|| OIDC_DEFAULT_USERNAME_CLAIM.to_string());
|
||||
let display_name = kvs.lookup(OIDC_DISPLAY_NAME).unwrap_or_else(|| id.to_string());
|
||||
let redirect_uri = kvs.lookup(OIDC_REDIRECT_URI).filter(|v| !v.is_empty());
|
||||
let client_secret = kvs.lookup(OIDC_CLIENT_SECRET).filter(|v| !v.is_empty());
|
||||
|
||||
Some(OidcProviderConfig {
|
||||
id: id.to_string(),
|
||||
enabled,
|
||||
config_url,
|
||||
client_id: kvs.get(OIDC_CLIENT_ID),
|
||||
client_secret,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
redirect_uri_dynamic,
|
||||
claim_name,
|
||||
claim_prefix: kvs.get(OIDC_CLAIM_PREFIX),
|
||||
role_policy: kvs.get(OIDC_ROLE_POLICY),
|
||||
display_name,
|
||||
groups_claim,
|
||||
email_claim,
|
||||
username_claim,
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform OIDC discovery for a provider.
|
||||
/// `discover_async` fetches the discovery document and JWKS in one step.
|
||||
async fn discover_provider(config: &OidcProviderConfig, http_client: &ReqwestHttpClient) -> Result<ProviderState, String> {
|
||||
@@ -736,6 +848,64 @@ impl OidcSys {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_oidc_provider_configs_from_env() -> Vec<OidcProviderConfig> {
|
||||
OidcSys::parse_env_configs()
|
||||
}
|
||||
|
||||
pub fn load_oidc_provider_configs_from_server_config(cfg: &ServerConfig) -> Vec<OidcProviderConfig> {
|
||||
OidcSys::parse_persisted_configs(cfg)
|
||||
}
|
||||
|
||||
pub fn merge_oidc_provider_configs(
|
||||
env_configs: Vec<OidcProviderConfig>,
|
||||
persisted_configs: Vec<OidcProviderConfig>,
|
||||
) -> Vec<SourcedOidcProviderConfig> {
|
||||
let mut effective = HashMap::new();
|
||||
|
||||
for config in persisted_configs {
|
||||
effective.insert(
|
||||
config.id.clone(),
|
||||
SourcedOidcProviderConfig {
|
||||
config,
|
||||
source: OidcProviderConfigSource::Persisted,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for config in env_configs {
|
||||
effective.insert(
|
||||
config.id.clone(),
|
||||
SourcedOidcProviderConfig {
|
||||
config,
|
||||
source: OidcProviderConfigSource::Env,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut configs: Vec<SourcedOidcProviderConfig> = effective.into_values().collect();
|
||||
configs.sort_by(|lhs, rhs| lhs.config.id.cmp(&rhs.config.id));
|
||||
configs
|
||||
}
|
||||
|
||||
pub fn load_effective_oidc_provider_configs(server_config: Option<&ServerConfig>) -> Vec<SourcedOidcProviderConfig> {
|
||||
let env_configs = load_oidc_provider_configs_from_env();
|
||||
let persisted_configs = server_config
|
||||
.map(load_oidc_provider_configs_from_server_config)
|
||||
.unwrap_or_default();
|
||||
merge_oidc_provider_configs(env_configs, persisted_configs)
|
||||
}
|
||||
|
||||
pub async fn validate_oidc_provider_config(config: &OidcProviderConfig) -> Result<OidcProviderValidationResult, String> {
|
||||
let http_client = ReqwestHttpClient(reqwest::Client::new());
|
||||
let state = OidcSys::discover_provider(config, &http_client).await?;
|
||||
|
||||
Ok(OidcProviderValidationResult {
|
||||
issuer: state.metadata.issuer().to_string(),
|
||||
authorization_endpoint: state.metadata.authorization_endpoint().to_string(),
|
||||
token_endpoint: state.metadata.token_endpoint().map(ToString::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
fn normalize_issuer(raw: &str) -> Option<(String, String, u16, String)> {
|
||||
@@ -1019,6 +1189,59 @@ mod tests {
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_persisted_provider_config() {
|
||||
let mut cfg = ServerConfig::new();
|
||||
let mut kvs = KVS(vec![
|
||||
rustfs_ecstore::config::KV {
|
||||
key: ENABLE_KEY.to_string(),
|
||||
value: EnableState::Off.to_string(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
rustfs_ecstore::config::KV {
|
||||
key: OIDC_CONFIG_URL.to_string(),
|
||||
value: String::new(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
rustfs_ecstore::config::KV {
|
||||
key: OIDC_CLIENT_ID.to_string(),
|
||||
value: String::new(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
]);
|
||||
kvs.insert(
|
||||
OIDC_CONFIG_URL.to_string(),
|
||||
"https://example.com/.well-known/openid-configuration".to_string(),
|
||||
);
|
||||
kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string());
|
||||
kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
|
||||
|
||||
cfg.0
|
||||
.entry(IDENTITY_OPENID_SUB_SYS.to_string())
|
||||
.or_default()
|
||||
.insert(DEFAULT_DELIMITER.to_string(), kvs);
|
||||
|
||||
let parsed = OidcSys::parse_persisted_configs(&cfg);
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].id, "default");
|
||||
assert_eq!(parsed[0].client_id, "console");
|
||||
assert!(parsed[0].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_oidc_provider_configs_prefers_env() {
|
||||
let mut persisted = test_config("default");
|
||||
persisted.display_name = "Persisted".to_string();
|
||||
|
||||
let mut env = test_config("default");
|
||||
env.display_name = "Environment".to_string();
|
||||
|
||||
let merged = merge_oidc_provider_configs(vec![env], vec![persisted]);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].config.display_name, "Environment");
|
||||
assert_eq!(merged[0].source, OidcProviderConfigSource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_sys_empty() {
|
||||
let sys = OidcSys::empty();
|
||||
|
||||
@@ -13,17 +13,34 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::sts::create_oidc_sts_credentials;
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{ADMIN_PREFIX, MINIO_ADMIN_PREFIX, RemoteAddr};
|
||||
use http::StatusCode;
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
use rustfs_config::oidc::{
|
||||
IDENTITY_OPENID_SUB_SYS, OIDC_CLAIM_NAME, OIDC_CLAIM_PREFIX, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL,
|
||||
OIDC_DEFAULT_CLAIM_NAME, OIDC_DEFAULT_EMAIL_CLAIM, OIDC_DEFAULT_GROUPS_CLAIM, OIDC_DEFAULT_SCOPES,
|
||||
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_REDIRECT_URI,
|
||||
OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
|
||||
};
|
||||
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE};
|
||||
use rustfs_ecstore::config::com::{read_config_without_migrate, save_server_config};
|
||||
use rustfs_ecstore::config::{Config as ServerConfig, get_global_server_config};
|
||||
use rustfs_ecstore::new_object_layer_fn;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{error, info, warn};
|
||||
use url::Url;
|
||||
|
||||
const OIDC_PATH_PREFIX: &str = "/rustfs/admin/v3/oidc";
|
||||
const OIDC_PUBLIC_PROVIDERS_SUFFIX: &str = "/v3/oidc/providers";
|
||||
const OIDC_AUTHORIZE_SUFFIX: &str = "/v3/oidc/authorize/";
|
||||
const OIDC_CALLBACK_SUFFIX: &str = "/v3/oidc/callback/";
|
||||
|
||||
/// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen).
|
||||
fn is_valid_provider_id(id: &str) -> bool {
|
||||
@@ -57,13 +74,164 @@ pub fn register_oidc_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/callback/{{provider_id}}"),
|
||||
AdminOperation(&OidcCallbackHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::GET,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/config"),
|
||||
AdminOperation(&GetOidcConfigHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::PUT,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/config/{{provider_id}}"),
|
||||
AdminOperation(&PutOidcConfigHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::DELETE,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/config/{{provider_id}}"),
|
||||
AdminOperation(&DeleteOidcConfigHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::POST,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/validate"),
|
||||
AdminOperation(&ValidateOidcConfigHandler {}),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if the given path is an OIDC endpoint (requires unauthenticated access).
|
||||
pub fn is_oidc_path(path: &str) -> bool {
|
||||
path.starts_with(OIDC_PATH_PREFIX)
|
||||
let public_prefixes = [ADMIN_PREFIX, MINIO_ADMIN_PREFIX];
|
||||
|
||||
public_prefixes.iter().any(|prefix| {
|
||||
path == format!("{prefix}{OIDC_PUBLIC_PROVIDERS_SUFFIX}")
|
||||
|| path.starts_with(&format!("{prefix}{OIDC_AUTHORIZE_SUFFIX}"))
|
||||
|| path.starts_with(&format!("{prefix}{OIDC_CALLBACK_SUFFIX}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OidcConfigListResponse {
|
||||
providers: Vec<OidcConfigView>,
|
||||
restart_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OidcConfigView {
|
||||
provider_id: String,
|
||||
source: rustfs_iam::oidc::OidcProviderConfigSource,
|
||||
editable: bool,
|
||||
enabled: bool,
|
||||
display_name: String,
|
||||
config_url: String,
|
||||
client_id: String,
|
||||
client_secret_configured: bool,
|
||||
scopes: Vec<String>,
|
||||
redirect_uri: Option<String>,
|
||||
redirect_uri_dynamic: bool,
|
||||
claim_name: String,
|
||||
claim_prefix: String,
|
||||
role_policy: String,
|
||||
groups_claim: String,
|
||||
email_claim: String,
|
||||
username_claim: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OidcMutationResponse {
|
||||
success: bool,
|
||||
message: String,
|
||||
restart_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OidcValidationResponse {
|
||||
valid: bool,
|
||||
message: String,
|
||||
issuer: Option<String>,
|
||||
authorization_endpoint: Option<String>,
|
||||
token_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct OidcConfigUpsertRequest {
|
||||
enabled: bool,
|
||||
display_name: String,
|
||||
config_url: String,
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
scopes: Vec<String>,
|
||||
redirect_uri: Option<String>,
|
||||
redirect_uri_dynamic: bool,
|
||||
claim_name: String,
|
||||
claim_prefix: String,
|
||||
role_policy: String,
|
||||
groups_claim: String,
|
||||
email_claim: String,
|
||||
username_claim: String,
|
||||
}
|
||||
|
||||
impl Default for OidcConfigUpsertRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
display_name: String::new(),
|
||||
config_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(),
|
||||
redirect_uri: None,
|
||||
redirect_uri_dynamic: true,
|
||||
claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(),
|
||||
claim_prefix: String::new(),
|
||||
role_policy: String::new(),
|
||||
groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(),
|
||||
email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(),
|
||||
username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct OidcConfigValidateRequest {
|
||||
provider_id: String,
|
||||
enabled: bool,
|
||||
display_name: String,
|
||||
config_url: String,
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
scopes: Vec<String>,
|
||||
redirect_uri: Option<String>,
|
||||
redirect_uri_dynamic: bool,
|
||||
claim_name: String,
|
||||
claim_prefix: String,
|
||||
role_policy: String,
|
||||
groups_claim: String,
|
||||
email_claim: String,
|
||||
username_claim: String,
|
||||
}
|
||||
|
||||
impl Default for OidcConfigValidateRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider_id: "default".to_string(),
|
||||
enabled: true,
|
||||
display_name: String::new(),
|
||||
config_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(),
|
||||
redirect_uri: None,
|
||||
redirect_uri_dynamic: true,
|
||||
claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(),
|
||||
claim_prefix: String::new(),
|
||||
role_policy: String::new(),
|
||||
groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(),
|
||||
email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(),
|
||||
username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/providers
|
||||
@@ -86,6 +254,146 @@ impl Operation for ListOidcProvidersHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetOidcConfigHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for GetOidcConfigHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_oidc_config_request(&req, AdminAction::ServerInfoAdminAction).await?;
|
||||
|
||||
let config = load_server_config_from_store().await?;
|
||||
let restart_required = oidc_restart_required(&config);
|
||||
let providers = rustfs_iam::oidc::load_effective_oidc_provider_configs(Some(&config))
|
||||
.into_iter()
|
||||
.map(|provider| OidcConfigView {
|
||||
provider_id: provider.config.id.clone(),
|
||||
source: provider.source,
|
||||
editable: provider.source != rustfs_iam::oidc::OidcProviderConfigSource::Env,
|
||||
enabled: provider.config.enabled,
|
||||
display_name: provider.config.display_name.clone(),
|
||||
config_url: provider.config.config_url.clone(),
|
||||
client_id: provider.config.client_id.clone(),
|
||||
client_secret_configured: provider.config.client_secret.is_some(),
|
||||
scopes: provider.config.scopes.clone(),
|
||||
redirect_uri: provider.config.redirect_uri.clone(),
|
||||
redirect_uri_dynamic: provider.config.redirect_uri_dynamic,
|
||||
claim_name: provider.config.claim_name.clone(),
|
||||
claim_prefix: provider.config.claim_prefix.clone(),
|
||||
role_policy: provider.config.role_policy.clone(),
|
||||
groups_claim: provider.config.groups_claim.clone(),
|
||||
email_claim: provider.config.email_claim.clone(),
|
||||
username_claim: provider.config.username_claim.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
&OidcConfigListResponse {
|
||||
providers,
|
||||
restart_required,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PutOidcConfigHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for PutOidcConfigHandler {
|
||||
async fn call(&self, mut req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_oidc_config_request(&req, AdminAction::ConfigUpdateAdminAction).await?;
|
||||
|
||||
let provider_id = params
|
||||
.get("provider_id")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?;
|
||||
if !is_valid_provider_id(provider_id) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid provider_id"));
|
||||
}
|
||||
if is_env_managed_provider(provider_id) {
|
||||
return Err(s3_error!(AccessDenied, "provider is managed by environment variables"));
|
||||
}
|
||||
|
||||
let request: OidcConfigUpsertRequest = parse_json_body(&mut req).await?;
|
||||
let mut config = load_server_config_from_store().await?;
|
||||
let existing_secret = persisted_provider_secret(&config, provider_id);
|
||||
let provider_config = build_provider_config_from_upsert(provider_id, request, existing_secret)?;
|
||||
upsert_persisted_provider_config(&mut config, &provider_config);
|
||||
save_server_config_to_store(&config).await?;
|
||||
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
&OidcMutationResponse {
|
||||
success: true,
|
||||
message: "OIDC provider saved".to_string(),
|
||||
restart_required: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeleteOidcConfigHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for DeleteOidcConfigHandler {
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_oidc_config_request(&req, AdminAction::ConfigUpdateAdminAction).await?;
|
||||
|
||||
let provider_id = params
|
||||
.get("provider_id")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?;
|
||||
if !is_valid_provider_id(provider_id) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid provider_id"));
|
||||
}
|
||||
if is_env_managed_provider(provider_id) {
|
||||
return Err(s3_error!(AccessDenied, "provider is managed by environment variables"));
|
||||
}
|
||||
|
||||
let mut config = load_server_config_from_store().await?;
|
||||
delete_persisted_provider_config(&mut config, provider_id)?;
|
||||
save_server_config_to_store(&config).await?;
|
||||
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
&OidcMutationResponse {
|
||||
success: true,
|
||||
message: "OIDC provider deleted".to_string(),
|
||||
restart_required: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ValidateOidcConfigHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for ValidateOidcConfigHandler {
|
||||
async fn call(&self, mut req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_oidc_config_request(&req, AdminAction::ServerInfoAdminAction).await?;
|
||||
|
||||
let request: OidcConfigValidateRequest = parse_json_body(&mut req).await?;
|
||||
let provider_id = if request.provider_id.trim().is_empty() {
|
||||
"default".to_string()
|
||||
} else {
|
||||
request.provider_id.trim().to_string()
|
||||
};
|
||||
let provider_config = build_provider_config_from_validate(request, &provider_id)?;
|
||||
let validation = rustfs_iam::oidc::validate_oidc_provider_config(&provider_config)
|
||||
.await
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("validation failed: {e}")))?;
|
||||
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
&OidcValidationResponse {
|
||||
valid: true,
|
||||
message: "OIDC configuration is valid".to_string(),
|
||||
issuer: Some(validation.issuer),
|
||||
authorization_endpoint: Some(validation.authorization_endpoint),
|
||||
token_endpoint: validation.token_endpoint,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/authorize/:provider_id
|
||||
/// Generates PKCE challenge, stores state, and returns 302 redirect to IdP.
|
||||
pub struct OidcAuthorizeHandler {}
|
||||
@@ -299,6 +607,326 @@ fn build_console_redirect(
|
||||
Ok(format!("{scheme}://{host}{console_prefix}/auth/oidc-callback/#{fragment}"))
|
||||
}
|
||||
|
||||
async fn authorize_oidc_config_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
|
||||
let Some(input_cred) = &req.credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(action)],
|
||||
req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn parse_json_body<T: DeserializeOwned>(req: &mut S3Request<Body>) -> S3Result<T> {
|
||||
let body = req
|
||||
.input
|
||||
.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InvalidRequest, "failed to read request body: {}", e))?;
|
||||
|
||||
if body.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "request body is required"));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e))
|
||||
}
|
||||
|
||||
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let body = serde_json::to_vec(payload)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?;
|
||||
|
||||
let mut resp = S3Response::new((status, Body::from(body)));
|
||||
resp.headers
|
||||
.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/json"));
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn load_server_config_from_store() -> S3Result<ServerConfig> {
|
||||
let Some(store) = new_object_layer_fn() else {
|
||||
return Err(s3_error!(InternalError, "storage layer not initialized"));
|
||||
};
|
||||
|
||||
read_config_without_migrate(store)
|
||||
.await
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to load server config: {e}")))
|
||||
}
|
||||
|
||||
async fn save_server_config_to_store(config: &ServerConfig) -> S3Result<()> {
|
||||
let Some(store) = new_object_layer_fn() else {
|
||||
return Err(s3_error!(InternalError, "storage layer not initialized"));
|
||||
};
|
||||
|
||||
save_server_config(store, config)
|
||||
.await
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to save server config: {e}")))
|
||||
}
|
||||
|
||||
fn is_env_managed_provider(provider_id: &str) -> bool {
|
||||
rustfs_iam::oidc::load_oidc_provider_configs_from_env()
|
||||
.iter()
|
||||
.any(|config| config.id == provider_id)
|
||||
}
|
||||
|
||||
fn provider_instance_key(provider_id: &str) -> String {
|
||||
if provider_id == "default" {
|
||||
DEFAULT_DELIMITER.to_string()
|
||||
} else {
|
||||
provider_id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn oidc_restart_required(config: &ServerConfig) -> bool {
|
||||
let active_config = get_global_server_config();
|
||||
oidc_restart_required_from_active_config(config, active_config.as_ref())
|
||||
}
|
||||
|
||||
fn oidc_restart_required_from_active_config(config: &ServerConfig, active_config: Option<&ServerConfig>) -> bool {
|
||||
rustfs_iam::oidc::load_effective_oidc_provider_configs(Some(config))
|
||||
!= rustfs_iam::oidc::load_effective_oidc_provider_configs(active_config)
|
||||
}
|
||||
|
||||
fn default_oidc_kvs() -> s3s::S3Result<rustfs_ecstore::config::KVS> {
|
||||
ServerConfig::new()
|
||||
.get_value(IDENTITY_OPENID_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.ok_or_else(|| s3_error!(InternalError, "default OIDC configuration missing"))
|
||||
}
|
||||
|
||||
fn set_kvs_value(kvs: &mut rustfs_ecstore::config::KVS, key: &str, value: String) {
|
||||
if let Some(existing) = kvs.0.iter_mut().find(|kv| kv.key == key) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
kvs.insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
fn normalize_scopes(scopes: &[String]) -> Vec<String> {
|
||||
scopes
|
||||
.iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_optional(value: Option<String>) -> Option<String> {
|
||||
value.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn validate_absolute_http_url(value: &str, field_name: &str) -> S3Result<()> {
|
||||
let parsed = Url::parse(value).map_err(|_| s3_error!(InvalidRequest, "{} must be an absolute http/https URL", field_name))?;
|
||||
|
||||
if !is_valid_scheme(parsed.scheme()) || parsed.host_str().is_none() {
|
||||
return Err(s3_error!(InvalidRequest, "{} must be an absolute http/https URL", field_name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_provider_config_fields(config: &rustfs_iam::oidc::OidcProviderConfig) -> S3Result<()> {
|
||||
if !is_valid_provider_id(&config.id) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid provider_id"));
|
||||
}
|
||||
if config.config_url.trim().is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "config_url is required"));
|
||||
}
|
||||
validate_absolute_http_url(&config.config_url, "config_url")?;
|
||||
|
||||
if config.client_id.trim().is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "client_id is required"));
|
||||
}
|
||||
|
||||
if !config.redirect_uri_dynamic {
|
||||
let redirect_uri = config
|
||||
.redirect_uri
|
||||
.as_deref()
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "redirect_uri is required when redirect_uri_dynamic is off"))?;
|
||||
validate_absolute_http_url(redirect_uri, "redirect_uri")?;
|
||||
} else if let Some(redirect_uri) = config.redirect_uri.as_deref() {
|
||||
validate_absolute_http_url(redirect_uri, "redirect_uri")?;
|
||||
}
|
||||
|
||||
if !config.scopes.iter().any(|scope| scope == "openid") {
|
||||
return Err(s3_error!(InvalidRequest, "scopes must include openid"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_provider_config_from_upsert(
|
||||
provider_id: &str,
|
||||
request: OidcConfigUpsertRequest,
|
||||
existing_secret: Option<String>,
|
||||
) -> S3Result<rustfs_iam::oidc::OidcProviderConfig> {
|
||||
let scopes = normalize_scopes(&request.scopes);
|
||||
let client_secret = match request.client_secret {
|
||||
Some(value) if !value.trim().is_empty() => Some(value),
|
||||
_ => existing_secret.filter(|value| !value.trim().is_empty()),
|
||||
};
|
||||
|
||||
let config = rustfs_iam::oidc::OidcProviderConfig {
|
||||
id: provider_id.to_string(),
|
||||
enabled: request.enabled,
|
||||
config_url: request.config_url.trim().to_string(),
|
||||
client_id: request.client_id.trim().to_string(),
|
||||
client_secret,
|
||||
scopes,
|
||||
redirect_uri: normalize_optional(request.redirect_uri),
|
||||
redirect_uri_dynamic: request.redirect_uri_dynamic,
|
||||
claim_name: if request.claim_name.trim().is_empty() {
|
||||
OIDC_DEFAULT_CLAIM_NAME.to_string()
|
||||
} else {
|
||||
request.claim_name.trim().to_string()
|
||||
},
|
||||
claim_prefix: request.claim_prefix.trim().to_string(),
|
||||
role_policy: request.role_policy.trim().to_string(),
|
||||
display_name: if request.display_name.trim().is_empty() {
|
||||
provider_id.to_string()
|
||||
} else {
|
||||
request.display_name.trim().to_string()
|
||||
},
|
||||
groups_claim: if request.groups_claim.trim().is_empty() {
|
||||
OIDC_DEFAULT_GROUPS_CLAIM.to_string()
|
||||
} else {
|
||||
request.groups_claim.trim().to_string()
|
||||
},
|
||||
email_claim: if request.email_claim.trim().is_empty() {
|
||||
OIDC_DEFAULT_EMAIL_CLAIM.to_string()
|
||||
} else {
|
||||
request.email_claim.trim().to_string()
|
||||
},
|
||||
username_claim: if request.username_claim.trim().is_empty() {
|
||||
OIDC_DEFAULT_USERNAME_CLAIM.to_string()
|
||||
} else {
|
||||
request.username_claim.trim().to_string()
|
||||
},
|
||||
};
|
||||
|
||||
validate_provider_config_fields(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_provider_config_from_validate(
|
||||
request: OidcConfigValidateRequest,
|
||||
provider_id: &str,
|
||||
) -> S3Result<rustfs_iam::oidc::OidcProviderConfig> {
|
||||
let config = rustfs_iam::oidc::OidcProviderConfig {
|
||||
id: provider_id.to_string(),
|
||||
enabled: request.enabled,
|
||||
config_url: request.config_url.trim().to_string(),
|
||||
client_id: request.client_id.trim().to_string(),
|
||||
client_secret: request.client_secret.filter(|value| !value.trim().is_empty()),
|
||||
scopes: normalize_scopes(&request.scopes),
|
||||
redirect_uri: normalize_optional(request.redirect_uri),
|
||||
redirect_uri_dynamic: request.redirect_uri_dynamic,
|
||||
claim_name: if request.claim_name.trim().is_empty() {
|
||||
OIDC_DEFAULT_CLAIM_NAME.to_string()
|
||||
} else {
|
||||
request.claim_name.trim().to_string()
|
||||
},
|
||||
claim_prefix: request.claim_prefix.trim().to_string(),
|
||||
role_policy: request.role_policy.trim().to_string(),
|
||||
display_name: if request.display_name.trim().is_empty() {
|
||||
provider_id.to_string()
|
||||
} else {
|
||||
request.display_name.trim().to_string()
|
||||
},
|
||||
groups_claim: if request.groups_claim.trim().is_empty() {
|
||||
OIDC_DEFAULT_GROUPS_CLAIM.to_string()
|
||||
} else {
|
||||
request.groups_claim.trim().to_string()
|
||||
},
|
||||
email_claim: if request.email_claim.trim().is_empty() {
|
||||
OIDC_DEFAULT_EMAIL_CLAIM.to_string()
|
||||
} else {
|
||||
request.email_claim.trim().to_string()
|
||||
},
|
||||
username_claim: if request.username_claim.trim().is_empty() {
|
||||
OIDC_DEFAULT_USERNAME_CLAIM.to_string()
|
||||
} else {
|
||||
request.username_claim.trim().to_string()
|
||||
},
|
||||
};
|
||||
|
||||
validate_provider_config_fields(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn persisted_provider_secret(config: &ServerConfig, provider_id: &str) -> Option<String> {
|
||||
config
|
||||
.0
|
||||
.get(IDENTITY_OPENID_SUB_SYS)
|
||||
.and_then(|subsystem| subsystem.get(&provider_instance_key(provider_id)))
|
||||
.and_then(|kvs| kvs.lookup(OIDC_CLIENT_SECRET))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn upsert_persisted_provider_config(config: &mut ServerConfig, provider_config: &rustfs_iam::oidc::OidcProviderConfig) {
|
||||
let instance_key = provider_instance_key(&provider_config.id);
|
||||
let mut kvs = default_oidc_kvs().unwrap_or_default();
|
||||
|
||||
set_kvs_value(
|
||||
&mut kvs,
|
||||
ENABLE_KEY,
|
||||
if provider_config.enabled {
|
||||
EnableState::On.to_string()
|
||||
} else {
|
||||
EnableState::Off.to_string()
|
||||
},
|
||||
);
|
||||
set_kvs_value(&mut kvs, OIDC_CONFIG_URL, provider_config.config_url.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_CLIENT_ID, provider_config.client_id.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_CLIENT_SECRET, provider_config.client_secret.clone().unwrap_or_default());
|
||||
set_kvs_value(&mut kvs, OIDC_SCOPES, provider_config.scopes.join(","));
|
||||
set_kvs_value(&mut kvs, OIDC_REDIRECT_URI, provider_config.redirect_uri.clone().unwrap_or_default());
|
||||
set_kvs_value(
|
||||
&mut kvs,
|
||||
OIDC_REDIRECT_URI_DYNAMIC,
|
||||
if provider_config.redirect_uri_dynamic {
|
||||
EnableState::On.to_string()
|
||||
} else {
|
||||
EnableState::Off.to_string()
|
||||
},
|
||||
);
|
||||
set_kvs_value(&mut kvs, OIDC_CLAIM_NAME, provider_config.claim_name.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_CLAIM_PREFIX, provider_config.claim_prefix.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_ROLE_POLICY, provider_config.role_policy.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_DISPLAY_NAME, provider_config.display_name.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_GROUPS_CLAIM, provider_config.groups_claim.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_EMAIL_CLAIM, provider_config.email_claim.clone());
|
||||
set_kvs_value(&mut kvs, OIDC_USERNAME_CLAIM, provider_config.username_claim.clone());
|
||||
|
||||
config
|
||||
.0
|
||||
.entry(IDENTITY_OPENID_SUB_SYS.to_string())
|
||||
.or_default()
|
||||
.insert(instance_key, kvs);
|
||||
}
|
||||
|
||||
fn delete_persisted_provider_config(config: &mut ServerConfig, provider_id: &str) -> S3Result<()> {
|
||||
let Some(subsystem) = config.0.get_mut(IDENTITY_OPENID_SUB_SYS) else {
|
||||
return Err(s3_error!(InvalidRequest, "provider not found"));
|
||||
};
|
||||
|
||||
if subsystem.remove(&provider_instance_key(provider_id)).is_none() {
|
||||
return Err(s3_error!(InvalidRequest, "provider not found"));
|
||||
}
|
||||
|
||||
if subsystem.is_empty() {
|
||||
config.0.remove(IDENTITY_OPENID_SUB_SYS);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_request_scheme(req: &S3Request<Body>) -> S3Result<String> {
|
||||
let raw_scheme = req
|
||||
.headers
|
||||
@@ -364,6 +992,13 @@ mod tests {
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/providers"));
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/authorize/okta"));
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/callback/okta"));
|
||||
assert!(is_oidc_path("/minio/admin/v3/oidc/providers"));
|
||||
assert!(is_oidc_path("/minio/admin/v3/oidc/authorize/okta"));
|
||||
assert!(is_oidc_path("/minio/admin/v3/oidc/callback/okta"));
|
||||
assert!(!is_oidc_path("/rustfs/admin/v3/oidc/config"));
|
||||
assert!(!is_oidc_path("/rustfs/admin/v3/oidc/config/default"));
|
||||
assert!(!is_oidc_path("/rustfs/admin/v3/oidc/validate"));
|
||||
assert!(!is_oidc_path("/minio/admin/v3/oidc/config"));
|
||||
assert!(!is_oidc_path("/rustfs/admin/v3/users"));
|
||||
assert!(!is_oidc_path("/health"));
|
||||
}
|
||||
@@ -459,4 +1094,65 @@ mod tests {
|
||||
assert!(!is_valid_scheme("javascript"));
|
||||
assert!(!is_valid_scheme(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_instance_key() {
|
||||
assert_eq!(provider_instance_key("default"), "_");
|
||||
assert_eq!(provider_instance_key("okta"), "okta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_provider_config_requires_openid_scope() {
|
||||
let req = OidcConfigUpsertRequest {
|
||||
scopes: vec!["profile".to_string()],
|
||||
config_url: "https://example.com/.well-known/openid-configuration".to_string(),
|
||||
client_id: "client-id".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(build_provider_config_from_upsert("default", req, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_provider_config_preserves_existing_secret_when_request_is_empty() {
|
||||
let req = OidcConfigUpsertRequest {
|
||||
config_url: "https://example.com/.well-known/openid-configuration".to_string(),
|
||||
client_id: "client-id".to_string(),
|
||||
client_secret: Some("".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config =
|
||||
build_provider_config_from_upsert("default", req, Some("existing-secret".to_string())).expect("config should build");
|
||||
|
||||
assert_eq!(config.client_secret.as_deref(), Some("existing-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_restart_required_detects_persisted_changes() {
|
||||
let active_config = ServerConfig::new();
|
||||
let mut persisted_config = ServerConfig::new();
|
||||
let provider_config = rustfs_iam::oidc::OidcProviderConfig {
|
||||
id: "default".to_string(),
|
||||
enabled: true,
|
||||
config_url: "https://example.com/.well-known/openid-configuration".to_string(),
|
||||
client_id: "console".to_string(),
|
||||
client_secret: Some("secret".to_string()),
|
||||
scopes: vec!["openid".to_string(), "profile".to_string()],
|
||||
redirect_uri: None,
|
||||
redirect_uri_dynamic: true,
|
||||
claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(),
|
||||
claim_prefix: String::new(),
|
||||
role_policy: String::new(),
|
||||
display_name: "default".to_string(),
|
||||
groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(),
|
||||
email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(),
|
||||
username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(),
|
||||
};
|
||||
|
||||
upsert_persisted_provider_config(&mut persisted_config, &provider_config);
|
||||
|
||||
assert!(oidc_restart_required_from_active_config(&persisted_config, Some(&active_config)));
|
||||
assert!(!oidc_restart_required_from_active_config(&persisted_config, Some(&persisted_config)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::{
|
||||
handlers::{bucket_meta, heal, health, kms, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user},
|
||||
handlers::{
|
||||
bucket_meta, heal, health, kms, oidc, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user,
|
||||
},
|
||||
router::{AdminOperation, S3Router},
|
||||
};
|
||||
use crate::server::{ADMIN_PREFIX, HEALTH_PREFIX, HEALTH_READY_PATH, MINIO_ADMIN_PREFIX, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH};
|
||||
@@ -53,6 +55,7 @@ fn test_register_routes_cover_representative_admin_paths() {
|
||||
replication::register_replication_route(&mut router).expect("register replication route");
|
||||
profile_admin::register_profiling_route(&mut router).expect("register profile route");
|
||||
kms::register_kms_route(&mut router).expect("register kms route");
|
||||
oidc::register_oidc_route(&mut router).expect("register oidc route");
|
||||
assert_route(&router, Method::GET, HEALTH_PREFIX);
|
||||
assert_route(&router, Method::HEAD, HEALTH_PREFIX);
|
||||
assert_route(&router, Method::GET, HEALTH_READY_PATH);
|
||||
@@ -114,6 +117,13 @@ fn test_register_routes_cover_representative_admin_paths() {
|
||||
assert_route(&router, Method::POST, &admin_path("/v3/kms/keys"));
|
||||
assert_route(&router, Method::GET, &admin_path("/v3/kms/keys"));
|
||||
assert_route(&router, Method::GET, &admin_path("/v3/kms/keys/test-key"));
|
||||
assert_route(&router, Method::GET, &admin_path("/v3/oidc/providers"));
|
||||
assert_route(&router, Method::GET, &admin_path("/v3/oidc/config"));
|
||||
assert_route(&router, Method::PUT, &admin_path("/v3/oidc/config/default"));
|
||||
assert_route(&router, Method::DELETE, &admin_path("/v3/oidc/config/default"));
|
||||
assert_route(&router, Method::POST, &admin_path("/v3/oidc/validate"));
|
||||
assert_route(&router, Method::GET, &admin_path("/v3/oidc/authorize/default"));
|
||||
assert_route(&router, Method::GET, &admin_path("/v3/oidc/callback/default"));
|
||||
|
||||
assert!(
|
||||
!router.contains_route(Method::GET, "/rustfs/rpc/read_file_stream"),
|
||||
@@ -132,6 +142,7 @@ fn test_admin_alias_paths_match_existing_admin_routes() {
|
||||
pools::register_pool_route(&mut router).expect("register pool route");
|
||||
rebalance::register_rebalance_route(&mut router).expect("register rebalance route");
|
||||
quota::register_quota_route(&mut router).expect("register quota route");
|
||||
oidc::register_oidc_route(&mut router).expect("register oidc route");
|
||||
|
||||
for (method, path) in [
|
||||
(Method::GET, compat_admin_alias_path("/v3/is-admin")),
|
||||
@@ -149,6 +160,11 @@ fn test_admin_alias_paths_match_existing_admin_routes() {
|
||||
(Method::POST, compat_admin_alias_path("/v3/idp/builtin/policy/detach")),
|
||||
(Method::GET, compat_admin_alias_path("/v3/idp/builtin/policy-entities")),
|
||||
(Method::POST, compat_admin_alias_path("/v3/rebalance/start")),
|
||||
(Method::GET, compat_admin_alias_path("/v3/oidc/providers")),
|
||||
(Method::GET, compat_admin_alias_path("/v3/oidc/authorize/default")),
|
||||
(Method::GET, compat_admin_alias_path("/v3/oidc/callback/default")),
|
||||
(Method::GET, compat_admin_alias_path("/v3/oidc/config")),
|
||||
(Method::PUT, compat_admin_alias_path("/v3/oidc/config/default")),
|
||||
] {
|
||||
assert!(
|
||||
router.contains_compatible_route(method.clone(), &path),
|
||||
|
||||
Reference in New Issue
Block a user