mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-13 16:46:55 +00:00
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:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user