feat(oidc): add roles_claim and jwt:roles policy support (#2509)

Co-authored-by: GatewayJ <8352692332qq.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
This commit is contained in:
GatewayJ
2026-04-15 09:30:24 +08:00
committed by GitHub
parent 1979fc7fb1
commit 16b9189e9b
9 changed files with 390 additions and 37 deletions
+34 -2
View File
@@ -22,9 +22,9 @@ 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_CLAIM_NAME, OIDC_DEFAULT_EMAIL_CLAIM, OIDC_DEFAULT_GROUPS_CLAIM, OIDC_DEFAULT_ROLES_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,
OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, 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};
@@ -132,6 +132,7 @@ struct OidcConfigView {
claim_prefix: String,
role_policy: String,
groups_claim: String,
roles_claim: String,
email_claim: String,
username_claim: String,
}
@@ -167,6 +168,7 @@ struct OidcConfigUpsertRequest {
claim_prefix: String,
role_policy: String,
groups_claim: String,
roles_claim: String,
email_claim: String,
username_claim: String,
}
@@ -186,6 +188,7 @@ impl Default for OidcConfigUpsertRequest {
claim_prefix: String::new(),
role_policy: String::new(),
groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(),
roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(),
email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(),
username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(),
}
@@ -208,6 +211,7 @@ struct OidcConfigValidateRequest {
claim_prefix: String,
role_policy: String,
groups_claim: String,
roles_claim: String,
email_claim: String,
username_claim: String,
}
@@ -228,6 +232,7 @@ impl Default for OidcConfigValidateRequest {
claim_prefix: String::new(),
role_policy: String::new(),
groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(),
roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(),
email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(),
username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(),
}
@@ -281,6 +286,7 @@ impl Operation for GetOidcConfigHandler {
claim_prefix: provider.config.claim_prefix.clone(),
role_policy: provider.config.role_policy.clone(),
groups_claim: provider.config.groups_claim.clone(),
roles_claim: provider.config.roles_claim.clone(),
email_claim: provider.config.email_claim.clone(),
username_claim: provider.config.username_claim.clone(),
})
@@ -798,6 +804,11 @@ fn build_provider_config_from_upsert(
} else {
request.groups_claim.trim().to_string()
},
roles_claim: if request.roles_claim.trim().is_empty() {
OIDC_DEFAULT_ROLES_CLAIM.to_string()
} else {
request.roles_claim.trim().to_string()
},
email_claim: if request.email_claim.trim().is_empty() {
OIDC_DEFAULT_EMAIL_CLAIM.to_string()
} else {
@@ -844,6 +855,11 @@ fn build_provider_config_from_validate(
} else {
request.groups_claim.trim().to_string()
},
roles_claim: if request.roles_claim.trim().is_empty() {
OIDC_DEFAULT_ROLES_CLAIM.to_string()
} else {
request.roles_claim.trim().to_string()
},
email_claim: if request.email_claim.trim().is_empty() {
OIDC_DEFAULT_EMAIL_CLAIM.to_string()
} else {
@@ -901,6 +917,7 @@ fn upsert_persisted_provider_config(config: &mut ServerConfig, provider_config:
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_ROLES_CLAIM, provider_config.roles_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());
@@ -1126,6 +1143,20 @@ mod tests {
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"));
assert_eq!(config.roles_claim, OIDC_DEFAULT_ROLES_CLAIM);
}
#[test]
fn test_build_provider_config_uses_custom_roles_claim() {
let req = OidcConfigUpsertRequest {
config_url: "https://example.com/.well-known/openid-configuration".to_string(),
client_id: "client-id".to_string(),
roles_claim: "app_roles".to_string(),
..Default::default()
};
let config = build_provider_config_from_upsert("default", req, None).expect("config should build");
assert_eq!(config.roles_claim, "app_roles");
}
#[test]
@@ -1146,6 +1177,7 @@ mod tests {
role_policy: String::new(),
display_name: "default".to_string(),
groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(),
roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(),
email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(),
username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(),
};
+106 -19
View File
@@ -18,7 +18,7 @@ use crate::{
handlers::site_replication::site_replication_iam_change_hook,
router::{AdminOperation, Operation, S3Router},
},
auth::{check_key_valid, get_session_token},
auth::{check_key_valid, extract_string_list_claim, get_session_token},
server::ADMIN_PREFIX,
};
use http::StatusCode;
@@ -47,6 +47,50 @@ const ASSUME_ROLE_ACTION: &str = "AssumeRole";
const ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: &str = "AssumeRoleWithWebIdentity";
const ASSUME_ROLE_VERSION: &str = "2011-06-15";
fn has_identity_authorization_context(policies: &[String], groups: &[String]) -> bool {
!policies.is_empty() || !groups.is_empty()
}
fn configured_roles_claim_key(provider_id: &str) -> Option<String> {
rustfs_iam::get_oidc()
.as_ref()
.and_then(|oidc_sys| oidc_sys.get_provider_config(provider_id))
.map(|cfg| cfg.roles_claim.trim().to_string())
.filter(|claim| !claim.is_empty())
}
fn build_oidc_token_claims(
claims: &OidcClaims,
provider_id: &str,
groups: &[String],
roles_claim_key: Option<&str>,
) -> HashMap<String, Value> {
let mut token_claims: HashMap<String, Value> = HashMap::new();
token_claims.insert("sub".to_string(), Value::String(claims.sub.clone()));
token_claims.insert("iss".to_string(), Value::String("rustfs-oidc".to_string()));
token_claims.insert("oidc_provider".to_string(), Value::String(provider_id.to_string()));
if !claims.email.is_empty() {
token_claims.insert("email".to_string(), Value::String(claims.email.clone()));
}
if !claims.username.is_empty() {
token_claims.insert("preferred_username".to_string(), Value::String(claims.username.clone()));
}
if !groups.is_empty() {
token_claims.insert(
"groups".to_string(),
Value::Array(groups.iter().map(|g| Value::String(g.clone())).collect()),
);
}
if let Some(roles_claim_key) = roles_claim_key {
let roles = extract_string_list_claim(&claims.raw, roles_claim_key);
if !roles.is_empty() {
token_claims.insert("roles".to_string(), Value::Array(roles.into_iter().map(Value::String).collect()));
}
}
token_claims
}
pub fn register_admin_auth_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(Method::POST, "/", AdminOperation(&AssumeRoleHandle {}))?;
@@ -241,7 +285,7 @@ async fn handle_assume_role_with_web_identity(body: AssumeRoleRequest) -> S3Resu
// Map claims to policies and groups
let (policies, groups) = oidc_sys.map_claims_to_policies(&provider_id, &claims);
if policies.is_empty() && groups.is_empty() {
if !has_identity_authorization_context(&policies, &groups) {
return Err(s3_error!(InvalidArgument, "no policies are available for this OIDC token"));
}
@@ -323,23 +367,8 @@ pub async fn create_oidc_sts_credentials(
duration_seconds: usize,
session_policy: Option<&str>,
) -> S3Result<rustfs_credentials::Credentials> {
let mut token_claims: HashMap<String, Value> = HashMap::new();
token_claims.insert("sub".to_string(), Value::String(claims.sub.clone()));
token_claims.insert("iss".to_string(), Value::String("rustfs-oidc".to_string()));
token_claims.insert("oidc_provider".to_string(), Value::String(provider_id.to_string()));
if !claims.email.is_empty() {
token_claims.insert("email".to_string(), Value::String(claims.email.clone()));
}
if !claims.username.is_empty() {
token_claims.insert("preferred_username".to_string(), Value::String(claims.username.clone()));
}
if !groups.is_empty() {
token_claims.insert(
"groups".to_string(),
Value::Array(groups.iter().map(|g| Value::String(g.clone())).collect()),
);
}
let roles_claim_key = configured_roles_claim_key(provider_id);
let mut token_claims = build_oidc_token_claims(claims, provider_id, groups, roles_claim_key.as_deref());
// Set expiration
let exp = OffsetDateTime::now_utc().saturating_add(Duration::seconds(duration_seconds as i64));
@@ -484,4 +513,62 @@ mod tests {
assert_eq!(clamp(43200), 43200); // exact max
assert_eq!(clamp(999999), 43200); // clamped to max
}
#[test]
fn test_has_identity_authorization_context() {
let empty: Vec<String> = vec![];
let groups = vec!["RustFS.ConsoleAdmin".to_string()];
let policies = vec!["consoleAdmin".to_string()];
assert!(!has_identity_authorization_context(&empty, &empty));
assert!(has_identity_authorization_context(&policies, &empty));
assert!(has_identity_authorization_context(&empty, &groups));
}
#[test]
fn test_extract_string_list_claim_supports_array_and_csv() {
let mut claims = HashMap::new();
claims.insert("roles".to_string(), serde_json::json!(["admin", "reader"]));
claims.insert("groups".to_string(), serde_json::json!("devs, ops"));
assert_eq!(extract_string_list_claim(&claims, "roles"), vec!["admin", "reader"]);
assert_eq!(extract_string_list_claim(&claims, "groups"), vec!["devs", "ops"]);
}
#[test]
fn test_extract_string_list_claim_prefers_exact_match() {
let mut claims = HashMap::new();
claims.insert("Roles".to_string(), serde_json::json!(["mixed-case"]));
claims.insert("roles".to_string(), serde_json::json!(["exact-match"]));
assert_eq!(extract_string_list_claim(&claims, "roles"), vec!["exact-match"]);
}
#[test]
fn test_extract_string_list_claim_ambiguous_case_insensitive_match_returns_empty() {
let mut claims = HashMap::new();
claims.insert("Roles".to_string(), serde_json::json!(["mixed-case"]));
claims.insert("ROLES".to_string(), serde_json::json!(["upper-case"]));
assert!(extract_string_list_claim(&claims, "roles").is_empty());
}
#[test]
fn test_build_oidc_token_claims_includes_normalized_roles() {
let mut raw = HashMap::new();
raw.insert("Roles".to_string(), serde_json::json!("admin, reader"));
let claims = OidcClaims {
sub: "user-sub".to_string(),
raw,
..Default::default()
};
let token_claims = build_oidc_token_claims(&claims, "default", &["devs".to_string()], Some("roles"));
assert_eq!(token_claims.get("roles"), Some(&serde_json::json!(["admin", "reader"])));
}
#[test]
fn test_configured_roles_claim_key_requires_explicit_config() {
assert_eq!(configured_roles_claim_key("default"), None);
}
}
+65 -10
View File
@@ -19,6 +19,7 @@ use rustfs_iam::error::Error as IamError;
use rustfs_iam::sys::{
SESSION_POLICY_NAME, get_claims_from_token_with_secret, get_claims_from_token_with_secret_allow_missing_exp,
};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use rustfs_utils::http::ip::get_source_ip_raw;
use s3s::S3Error;
use s3s::S3ErrorCode;
@@ -415,6 +416,19 @@ pub fn get_session_token<'a>(uri: &'a Uri, hds: &'a HeaderMap) -> Option<&'a str
token
}
pub(crate) fn extract_string_list_claim(claims: &HashMap<String, Value>, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(Value::Array(values)) => values.iter().filter_map(|v| v.as_str().map(ToOwned::to_owned)).collect(),
ClaimLookup::Found(Value::String(value)) => value
.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
/// Get condition values for policy evaluation
///
/// # Arguments
@@ -611,16 +625,14 @@ pub fn get_condition_values_with_query(
}
}
if let Some(grps_val) = claims.get("groups")
&& let Some(grps_is) = grps_val.as_array()
{
let grps = grps_is
.iter()
.filter_map(|g| g.as_str().map(|s| s.to_string()))
.collect::<Vec<String>>();
if !grps.is_empty() {
args.insert("groups".to_string(), grps);
}
let grps = extract_string_list_claim(claims, "groups");
if !grps.is_empty() {
args.insert("groups".to_string(), grps);
}
let roles = extract_string_list_claim(claims, "roles");
if !roles.is_empty() {
args.insert("roles".to_string(), roles);
}
}
@@ -1208,6 +1220,49 @@ mod tests {
assert_eq!(conditions.get("groups"), Some(&vec!["group1".to_string(), "group2".to_string()]));
}
#[test]
fn test_get_condition_values_with_roles_claim_array() {
let mut cred = create_service_account_credentials();
let mut claims = HashMap::new();
claims.insert("roles".to_string(), json!(["role1", "role2"]));
cred.claims = Some(claims);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("roles"), Some(&vec!["role1".to_string(), "role2".to_string()]));
}
#[test]
fn test_get_condition_values_with_roles_claim_csv_and_case_insensitive() {
let mut cred = create_service_account_credentials();
let mut claims = HashMap::new();
claims.insert("Roles".to_string(), json!("role1, role2"));
cred.claims = Some(claims);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("roles"), Some(&vec!["role1".to_string(), "role2".to_string()]));
}
#[test]
fn test_get_condition_values_with_roles_claim_ambiguous_case_insensitive_match_returns_empty() {
let mut cred = create_service_account_credentials();
let mut claims = HashMap::new();
claims.insert("Roles".to_string(), json!(["role1"]));
claims.insert("ROLES".to_string(), json!(["role2"]));
cred.claims = Some(claims);
let headers = HeaderMap::new();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("roles"), None);
}
#[test]
fn test_get_condition_values_with_credential_groups() {
let mut cred = create_test_credentials();