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
+42
View File
@@ -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/<tenant-id>/v2.0/.well-known/openid-configuration"
RUSTFS_IDENTITY_OPENID_CLIENT_ID="<client-id>"
RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="<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).
+7 -1
View File
@@ -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";
+8 -3
View File
@@ -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<KVS> = 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(),
+94 -2
View File
@@ -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<String, serde_json::Value>, key: &str)
}
}
fn extract_canonical_group_values(
claims: &HashMap<String, serde_json::Value>,
groups_claim: &str,
roles_claim: &str,
) -> Vec<String> {
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(),
};
@@ -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());
}
}
@@ -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")]
+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();