diff --git a/README.md b/README.md index 879ab3f7e..8819505eb 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,48 @@ rustfs --help **NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured.html). +### OIDC Roles Claim (Microsoft Entra ID) + +RustFS supports mapping an OIDC claim containing role values into the existing +authorization pipeline. The `roles_claim` setting is **optional**: when unset or +empty, only the `groups` claim contributes to authorization (same as older +RustFS releases). For Microsoft Entra ID app roles, set `roles_claim=roles` so +both console admin checks and bucket IAM policies can evaluate those roles. + +Example environment configuration (opt-in roles claim): + +```bash +RUSTFS_IDENTITY_OPENID_ENABLE=on +RUSTFS_IDENTITY_OPENID_CONFIG_URL="https://login.microsoftonline.com//v2.0/.well-known/openid-configuration" +RUSTFS_IDENTITY_OPENID_CLIENT_ID="" +RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="" +RUSTFS_IDENTITY_OPENID_SCOPES="openid,profile,email" +RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM="groups" +RUSTFS_IDENTITY_OPENID_ROLES_CLAIM="roles" +``` + +Policy condition example (evaluate app roles directly with `jwt:roles`; when +`roles_claim` is configured, RustFS also merges those values into `jwt:groups` +for backward compatibility with older policies): + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["admin:*"], + "Resource": ["arn:aws:s3:::*"], + "Condition": { + "ForAnyValue:StringEquals": { + "jwt:roles": ["RustFS.ConsoleAdmin"] + } + } + } + ] +} +``` + ## Documentation For detailed documentation, including configuration options, API references, and advanced usage, please visit our [Documentation](https://docs.rustfs.com). diff --git a/crates/config/src/constants/oidc.rs b/crates/config/src/constants/oidc.rs index c4bb73a90..493024737 100644 --- a/crates/config/src/constants/oidc.rs +++ b/crates/config/src/constants/oidc.rs @@ -24,6 +24,7 @@ pub const OIDC_CLAIM_PREFIX: &str = "claim_prefix"; pub const OIDC_ROLE_POLICY: &str = "role_policy"; pub const OIDC_DISPLAY_NAME: &str = "display_name"; pub const OIDC_GROUPS_CLAIM: &str = "groups_claim"; +pub const OIDC_ROLES_CLAIM: &str = "roles_claim"; pub const OIDC_EMAIL_CLAIM: &str = "email_claim"; pub const OIDC_USERNAME_CLAIM: &str = "username_claim"; @@ -40,11 +41,12 @@ pub const ENV_IDENTITY_OPENID_CLAIM_PREFIX: &str = "RUSTFS_IDENTITY_OPENID_CLAIM pub const ENV_IDENTITY_OPENID_ROLE_POLICY: &str = "RUSTFS_IDENTITY_OPENID_ROLE_POLICY"; pub const ENV_IDENTITY_OPENID_DISPLAY_NAME: &str = "RUSTFS_IDENTITY_OPENID_DISPLAY_NAME"; pub const ENV_IDENTITY_OPENID_GROUPS_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM"; +pub const ENV_IDENTITY_OPENID_ROLES_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_ROLES_CLAIM"; pub const ENV_IDENTITY_OPENID_EMAIL_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM"; pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM"; /// List of all environment variable keys for an OIDC provider. -pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 14] = &[ +pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 15] = &[ ENV_IDENTITY_OPENID_ENABLE, ENV_IDENTITY_OPENID_CONFIG_URL, ENV_IDENTITY_OPENID_CLIENT_ID, @@ -57,6 +59,7 @@ pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 14] = &[ ENV_IDENTITY_OPENID_ROLE_POLICY, ENV_IDENTITY_OPENID_DISPLAY_NAME, ENV_IDENTITY_OPENID_GROUPS_CLAIM, + ENV_IDENTITY_OPENID_ROLES_CLAIM, ENV_IDENTITY_OPENID_EMAIL_CLAIM, ENV_IDENTITY_OPENID_USERNAME_CLAIM, ]; @@ -75,6 +78,7 @@ pub const IDENTITY_OPENID_KEYS: &[&str] = &[ OIDC_ROLE_POLICY, OIDC_DISPLAY_NAME, OIDC_GROUPS_CLAIM, + OIDC_ROLES_CLAIM, OIDC_EMAIL_CLAIM, OIDC_USERNAME_CLAIM, crate::COMMENT_KEY, @@ -84,6 +88,8 @@ pub const IDENTITY_OPENID_KEYS: &[&str] = &[ pub const OIDC_DEFAULT_SCOPES: &str = "openid,profile,email"; pub const OIDC_DEFAULT_CLAIM_NAME: &str = "groups"; pub const OIDC_DEFAULT_GROUPS_CLAIM: &str = "groups"; +/// Empty means do not merge a secondary claim into groups (legacy behavior). Set to e.g. `roles` to opt in. +pub const OIDC_DEFAULT_ROLES_CLAIM: &str = ""; pub const OIDC_DEFAULT_EMAIL_CLAIM: &str = "email"; pub const OIDC_DEFAULT_USERNAME_CLAIM: &str = "preferred_username"; diff --git a/crates/ecstore/src/config/oidc.rs b/crates/ecstore/src/config/oidc.rs index 21de803a3..c89ffb8f3 100644 --- a/crates/ecstore/src/config/oidc.rs +++ b/crates/ecstore/src/config/oidc.rs @@ -17,9 +17,9 @@ use rustfs_config::{ ENABLE_KEY, EnableState, oidc::{ 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, + 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_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM, }, }; use std::sync::LazyLock; @@ -87,6 +87,11 @@ pub static DEFAULT_IDENTITY_OPENID_KVS: LazyLock = LazyLock::new(|| { value: OIDC_DEFAULT_GROUPS_CLAIM.to_owned(), hidden_if_empty: false, }, + KV { + key: OIDC_ROLES_CLAIM.to_owned(), + value: OIDC_DEFAULT_ROLES_CLAIM.to_owned(), + hidden_if_empty: false, + }, KV { key: OIDC_EMAIL_CLAIM.to_owned(), value: OIDC_DEFAULT_EMAIL_CLAIM.to_owned(), diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index 923e99c99..a6bfdc7ec 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -119,6 +119,7 @@ pub struct OidcProviderConfig { pub role_policy: String, pub display_name: String, pub groups_claim: String, + pub roles_claim: String, pub email_claim: String, pub username_claim: String, } @@ -385,7 +386,7 @@ impl OidcSys { sub: extract_string_claim(&raw, "sub"), email: extract_string_claim(&raw, &config.email_claim), username: extract_string_claim(&raw, &config.username_claim), - groups: extract_groups_claim(&raw, &config.groups_claim), + groups: extract_canonical_group_values(&raw, &config.groups_claim, &config.roles_claim), raw, }; @@ -509,7 +510,7 @@ impl OidcSys { sub: extract_string_claim(&raw_claims, "sub"), email: extract_string_claim(&raw_claims, &config.email_claim), username: extract_string_claim(&raw_claims, &config.username_claim), - groups: extract_groups_claim(&raw_claims, &config.groups_claim), + groups: extract_canonical_group_values(&raw_claims, &config.groups_claim, &config.roles_claim), raw: raw_claims, }; @@ -719,6 +720,7 @@ impl OidcSys { v } }; + let roles_claim = get_env(ENV_IDENTITY_OPENID_ROLES_CLAIM); let email_claim = { let v = get_env(ENV_IDENTITY_OPENID_EMAIL_CLAIM); if v.is_empty() { @@ -762,6 +764,7 @@ impl OidcSys { role_policy: get_env(ENV_IDENTITY_OPENID_ROLE_POLICY), display_name, groups_claim, + roles_claim, email_claim, username_claim, }) @@ -800,6 +803,9 @@ impl OidcSys { let groups_claim = kvs .lookup(OIDC_GROUPS_CLAIM) .unwrap_or_else(|| OIDC_DEFAULT_GROUPS_CLAIM.to_string()); + let roles_claim = kvs + .lookup(OIDC_ROLES_CLAIM) + .unwrap_or_else(|| OIDC_DEFAULT_ROLES_CLAIM.to_string()); let email_claim = kvs .lookup(OIDC_EMAIL_CLAIM) .unwrap_or_else(|| OIDC_DEFAULT_EMAIL_CLAIM.to_string()); @@ -824,6 +830,7 @@ impl OidcSys { role_policy: kvs.get(OIDC_ROLE_POLICY), display_name, groups_claim, + roles_claim, email_claim, username_claim, }) @@ -1025,6 +1032,21 @@ fn extract_groups_claim(claims: &HashMap, key: &str) } } +fn extract_canonical_group_values( + claims: &HashMap, + groups_claim: &str, + roles_claim: &str, +) -> Vec { + let mut groups = extract_groups_claim(claims, groups_claim); + if !roles_claim.is_empty() && roles_claim != groups_claim { + groups.extend(extract_groups_claim(claims, roles_claim)); + } + groups.retain(|g| !g.is_empty()); + groups.sort(); + groups.dedup(); + groups +} + #[cfg(test)] mod tests { use super::*; @@ -1073,6 +1095,34 @@ mod tests { assert!(groups.is_empty()); } + #[test] + fn test_extract_canonical_group_values_merges_groups_and_roles() { + let mut claims = HashMap::new(); + claims.insert("groups".to_string(), serde_json::json!(["devs", "admins"])); + claims.insert("roles".to_string(), serde_json::json!(["admins", "consoleAdmin"])); + + let merged = extract_canonical_group_values(&claims, "groups", "roles"); + assert_eq!(merged, vec!["admins", "consoleAdmin", "devs"]); + } + + #[test] + fn test_extract_canonical_group_values_skips_duplicate_claim_name() { + let mut claims = HashMap::new(); + claims.insert("roles".to_string(), serde_json::json!(["consoleAdmin"])); + + let merged = extract_canonical_group_values(&claims, "roles", "roles"); + assert_eq!(merged, vec!["consoleAdmin"]); + } + + #[test] + fn test_extract_canonical_group_values_roles_only() { + let mut claims = HashMap::new(); + claims.insert("roles".to_string(), serde_json::json!(["consoleAdmin", "bucket-reader"])); + + let merged = extract_canonical_group_values(&claims, "groups", "roles"); + assert_eq!(merged, vec!["bucket-reader", "consoleAdmin"]); + } + #[test] fn test_extract_string_claim_case_insensitive() { let mut claims = HashMap::new(); @@ -1246,6 +1296,7 @@ mod tests { role_policy: String::new(), display_name: "mock-oidc".to_string(), groups_claim: "groups".to_string(), + roles_claim: String::new(), email_claim: "email".to_string(), username_claim: "username".to_string(), } @@ -1491,6 +1542,7 @@ mod tests { ); kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string()); kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string()); + kvs.insert(OIDC_ROLES_CLAIM.to_string(), "app_roles".to_string()); cfg.0 .entry(IDENTITY_OPENID_SUB_SYS.to_string()) @@ -1502,6 +1554,44 @@ mod tests { assert_eq!(parsed[0].id, "default"); assert_eq!(parsed[0].client_id, "console"); assert!(parsed[0].enabled); + assert_eq!(parsed[0].roles_claim, "app_roles"); + } + + #[test] + fn test_parse_persisted_provider_config_omitted_roles_claim_is_empty() { + 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].roles_claim, ""); } #[test] @@ -1554,6 +1644,7 @@ mod tests { role_policy: "".to_string(), display_name: id.to_string(), groups_claim: "groups".to_string(), + roles_claim: String::new(), email_claim: "email".to_string(), username_claim: "preferred_username".to_string(), } @@ -1647,6 +1738,7 @@ mod tests { role_policy: "readwrite".to_string(), display_name: "Test Provider".to_string(), groups_claim: "groups".to_string(), + roles_claim: String::new(), email_claim: "email".to_string(), username_claim: "preferred_username".to_string(), }; diff --git a/crates/policy/src/policy/function/key_name.rs b/crates/policy/src/policy/function/key_name.rs index 6f4353aa7..ffc16d242 100644 --- a/crates/policy/src/policy/function/key_name.rs +++ b/crates/policy/src/policy/function/key_name.rs @@ -79,6 +79,7 @@ impl KeyName { KeyName::Jwt(JwtKeyName::JWTName), KeyName::Jwt(JwtKeyName::JWTUpn), KeyName::Jwt(JwtKeyName::JWTGroups), + KeyName::Jwt(JwtKeyName::JWTRoles), KeyName::Jwt(JwtKeyName::JWTGivenName), KeyName::Jwt(JwtKeyName::JWTFamilyName), KeyName::Jwt(JwtKeyName::JWTMiddleName), @@ -231,6 +232,9 @@ pub enum JwtKeyName { #[strum(serialize = "jwt:groups")] JWTGroups, + #[strum(serialize = "jwt:roles")] + JWTRoles, + #[strum(serialize = "jwt:given_name")] JWTGivenName, @@ -403,4 +407,9 @@ mod tests { let data = serde_json::to_string(&TestCase { data: value }).expect("marshal failed"); assert_eq!(data, except); } + + #[test] + fn key_name_from_str_supports_jwt_roles() { + assert!(KeyName::try_from("jwt:roles").is_ok()); + } } diff --git a/crates/policy/src/policy/function/string.rs b/crates/policy/src/policy/function/string.rs index f7207febc..3cd0ec601 100644 --- a/crates/policy/src/policy/function/string.rs +++ b/crates/policy/src/policy/function/string.rs @@ -320,6 +320,31 @@ mod tests { pollster::block_on(result) ^ negate } + #[test] + fn test_jwt_roles_condition_uses_roles_values() { + assert!(test_eval( + new_fkv("jwt:roles", vec!["RustFS.ConsoleAdmin"]), + false, + false, + false, + vec![("roles", vec!["RustFS.ConsoleAdmin"])] + )); + assert!(!test_eval( + new_fkv("jwt:roles", vec!["RustFS.ConsoleAdmin"]), + false, + false, + false, + vec![("roles", vec!["readonly"])] + )); + assert!(!test_eval( + new_fkv("jwt:roles", vec!["RustFS.ConsoleAdmin"]), + false, + false, + false, + vec![("groups", vec!["RustFS.ConsoleAdmin"])] + )); + } + #[test_case(new_fkv("s3:x-amz-copy-source", vec!["mybucket/myobject"]), false, vec![("x-amz-copy-source", vec!["mybucket/myobject"])] => true ; "1")] #[test_case(new_fkv("s3:x-amz-copy-source", vec!["mybucket/myobject"]), false, vec![("x-amz-copy-source", vec!["yourbucket/myobject"])] => false ; "2")] #[test_case(new_fkv("s3:x-amz-copy-source", vec!["mybucket/myobject"]), false, vec![] => false ; "3")] diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index 2bd3b1aa0..9062f806a 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -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(), }; diff --git a/rustfs/src/admin/handlers/sts.rs b/rustfs/src/admin/handlers/sts.rs index 447d10a18..568a4478d 100644 --- a/rustfs/src/admin/handlers/sts.rs +++ b/rustfs/src/admin/handlers/sts.rs @@ -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 { + 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 { + let mut token_claims: HashMap = 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) -> 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 { - let mut token_claims: HashMap = 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 = 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); + } } diff --git a/rustfs/src/auth.rs b/rustfs/src/auth.rs index 38cc8e911..70269b7ba 100644 --- a/rustfs/src/auth.rs +++ b/rustfs/src/auth.rs @@ -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, claim_name: &str) -> Vec { + 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::>(); - 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();