mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +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:
@@ -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).
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
@@ -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