-
Notifications
You must be signed in to change notification settings - Fork 499
[SSO] Add foundation for JWT group-to-role sync #36292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,10 @@ | ||
| --- | ||
| source: src/adapter-types/src/dyncfgs.rs | ||
| revision: 1505e0c3f0 | ||
| revision: 0c832dee79 | ||
| --- | ||
|
|
||
| # mz-adapter-types::dyncfgs | ||
|
|
||
| Declares all dynamic configuration flags owned by the adapter layer as `mz_dyncfg::Config` constants. | ||
| Covers session gating (`ALLOW_USER_SESSIONS`), zero-downtime deployment parameters (`WITH_0DT_*`, `ENABLE_0DT_*`), feature flags for expression caching, multi-replica sources, statement lifecycle logging, introspection subscribes, plan insights optimization thresholds, continual task builtins, password authentication, OIDC settings (`OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_AUTHENTICATION_CLAIM`), console OIDC configuration (`CONSOLE_OIDC_CLIENT_ID`, `CONSOLE_OIDC_SCOPES`), MCP endpoint toggles (`ENABLE_MCP_AGENT`, `ENABLE_MCP_AGENT_QUERY_TOOL`, `ENABLE_MCP_DEVELOPER`) and response size limit (`MCP_MAX_RESPONSE_SIZE`), persist fast-path ordering, S3 Tables region checks, and the user ID pool batch size. | ||
| Covers session gating (`ALLOW_USER_SESSIONS`), zero-downtime deployment parameters (`WITH_0DT_*`, `ENABLE_0DT_*`), feature flags for expression caching, multi-replica sources, statement lifecycle logging, introspection subscribes, plan insights optimization thresholds, continual task builtins, password authentication, OIDC settings (`OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_AUTHENTICATION_CLAIM`), OIDC JWT group-to-role sync (`OIDC_GROUP_ROLE_SYNC_ENABLED`, `OIDC_GROUP_CLAIM`, `OIDC_GROUP_ROLE_SYNC_STRICT`), console OIDC configuration (`CONSOLE_OIDC_CLIENT_ID`, `CONSOLE_OIDC_SCOPES`), MCP endpoint toggles (`ENABLE_MCP_AGENT`, `ENABLE_MCP_AGENT_QUERY_TOOL`, `ENABLE_MCP_DEVELOPER`) and response size limit (`MCP_MAX_RESPONSE_SIZE`), persist fast-path ordering, S3 Tables region checks, and the user ID pool batch size. | ||
| `all_dyncfgs` registers every config constant into a `ConfigSet` for use during bootstrap. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ | |
| //! This module provides JWT-based authentication using OpenID Connect (OIDC). | ||
| //! JWTs are validated locally using JWKS fetched from the configured provider. | ||
|
|
||
| use std::collections::BTreeMap; | ||
| use std::collections::{BTreeMap, BTreeSet}; | ||
| use std::sync::{Arc, Mutex}; | ||
| use std::time::Duration; | ||
|
|
||
|
|
@@ -244,6 +244,50 @@ impl OidcClaims { | |
| .get(authentication_claim) | ||
| .and_then(|value| value.as_str()) | ||
| } | ||
|
|
||
| /// Extracts group names from the specified JWT claim for group-to-role sync. | ||
| /// | ||
| /// Returns `None` if the claim is absent (skip sync, preserve current state), | ||
| /// `Some(vec![])` if the claim is present but empty (revoke all sync-granted | ||
| /// roles), or `Some(vec![...])` with normalized (lowercased, deduplicated, | ||
| /// sorted) group names. | ||
| /// | ||
| /// Accepts arrays of strings, single strings, or mixed arrays (non-string | ||
| /// elements are filtered out). Other JSON types are treated as absent. | ||
| pub fn groups(&self, claim_name: &str) -> Option<Vec<String>> { | ||
| let value = self.unknown_claims.get(claim_name)?; | ||
|
|
||
| let raw_groups: Vec<String> = match value { | ||
| serde_json::Value::Array(arr) => arr | ||
| .iter() | ||
| .filter_map(|v| v.as_str().map(String::from)) | ||
| .collect(), | ||
| serde_json::Value::String(s) => { | ||
| if s.is_empty() { | ||
| vec![] | ||
| } else { | ||
| vec![s.clone()] | ||
| } | ||
| } | ||
| _ => { | ||
| warn!( | ||
| claim_name, | ||
| "OIDC group claim has unexpected type; skipping group sync" | ||
| ); | ||
| return None; | ||
| } | ||
| }; | ||
|
|
||
| let normalized: Vec<String> = raw_groups | ||
| .into_iter() | ||
| .map(|g| g.trim().to_lowercase()) | ||
| .filter(|g| !g.is_empty()) | ||
| .collect::<BTreeSet<_>>() | ||
| .into_iter() | ||
| .collect(); | ||
|
|
||
| Some(normalized) | ||
| } | ||
| } | ||
|
|
||
| #[derive(Zeroize, ZeroizeOnDrop)] | ||
|
|
@@ -662,4 +706,251 @@ mod tests { | |
| assert!(claims.aud.is_empty()); | ||
| assert!(claims.unknown_claims.is_empty()); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_array() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["analytics","platform_eng"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec!["analytics".to_string(), "platform_eng".to_string()]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_single_string() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":"analytics"}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec!["analytics".to_string()])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_missing() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app"}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), None); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_empty_array() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec![])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_mixed_case() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Analytics","PLATFORM_ENG","analytics"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec!["analytics".to_string(), "platform_eng".to_string()]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_custom_claim_name() { | ||
| let json = | ||
| r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","roles":["admin","viewer"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("roles"), | ||
| Some(vec!["admin".to_string(), "viewer".to_string()]) | ||
| ); | ||
| assert_eq!(claims.groups("groups"), None); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_non_string_values_in_array() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["valid",123,true,"also_valid"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec!["also_valid".to_string(), "valid".to_string()]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_non_array_non_string() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":42}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), None); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_empty_string() { | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":""}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec![])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_null_claim() { | ||
| // Explicit null → treated as absent (not a valid group representation) | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":null}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), None); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_boolean_claim() { | ||
| // Boolean value → not array or string, treated as absent | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":true}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), None); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_object_claim() { | ||
| // JSON object → not array or string, treated as absent | ||
| let json = | ||
| r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":{"team":"eng"}}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), None); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_array_all_non_strings() { | ||
| // Array with zero valid string elements → Some(vec![]), not None | ||
| // (the claim *is* present, it just has no usable group names) | ||
| let json = | ||
| r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[1,2,true,null]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec![])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_array_with_nested_arrays() { | ||
| // Nested arrays are not strings, so they're filtered out | ||
| let json = | ||
| r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[["nested"],"valid"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec!["valid".to_string()])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_array_with_empty_strings() { | ||
| // Empty strings are not valid role names and are filtered out, | ||
| // consistent with the single-string case where "" → Some(vec![]). | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["","eng",""]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec!["eng".to_string()])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_whitespace_only_single_string() { | ||
| // Whitespace-only string trims to empty and is filtered out. | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":" "}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec![])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_whitespace_names() { | ||
| // Leading/trailing whitespace is trimmed from group names. | ||
| let json = | ||
| r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[" spaces ","eng"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec!["eng".to_string(), "spaces".to_string()]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_unicode_names() { | ||
| // Unicode group names should be lowercased correctly | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Développeurs","INGÉNIEURS"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec!["développeurs".to_string(), "ingénieurs".to_string()]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_special_characters() { | ||
| // Group names with special characters (hyphens, underscores, dots) | ||
| // are common in enterprise IdPs like Azure AD / Okta | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["team-platform.eng","org_data-science","role/admin"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec![ | ||
| "org_data-science".to_string(), | ||
| "role/admin".to_string(), | ||
| "team-platform.eng".to_string(), | ||
| ]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_case_insensitive_dedup() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Feels like |
||
| // "Eng" and "eng" and "ENG" should all collapse to one "eng" | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Eng","eng","ENG","eNg"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), Some(vec!["eng".to_string()])); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_large_array() { | ||
| // Verify we handle a reasonably large group list without issues | ||
| let groups: Vec<String> = (0..100).map(|i| format!("\"group_{}\"", i)).collect(); | ||
| let json = format!( | ||
| r#"{{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[{}]}}"#, | ||
| groups.join(",") | ||
| ); | ||
| let claims: OidcClaims = serde_json::from_str(&json).unwrap(); | ||
| let result = claims.groups("groups").unwrap(); | ||
| assert_eq!(result.len(), 100); | ||
| // BTreeSet ensures sorted order | ||
| assert_eq!(result[0], "group_0"); | ||
| assert_eq!(result[99], "group_99"); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_float_claim() { | ||
| // Float value → not array or string, treated as absent | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":3.14}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(claims.groups("groups"), None); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_array_with_null_elements() { | ||
| // Null elements in array are not strings, filtered out | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["eng",null,"ops",null]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec!["eng".to_string(), "ops".to_string()]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_array_with_object_elements() { | ||
| // Object elements in array are not strings, filtered out | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["eng",{"name":"ops"},"analytics"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec!["analytics".to_string(), "eng".to_string()]) | ||
| ); | ||
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn test_groups_sorted_output() { | ||
| // Verify output is sorted alphabetically regardless of input order | ||
| let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["zebra","alpha","mango","beta"]}"#; | ||
| let claims: OidcClaims = serde_json::from_str(json).unwrap(); | ||
| assert_eq!( | ||
| claims.groups("groups"), | ||
| Some(vec![ | ||
| "alpha".to_string(), | ||
| "beta".to_string(), | ||
| "mango".to_string(), | ||
| "zebra".to_string(), | ||
| ]) | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: I wonder if we're being too verbose with the comments? We say the same thing more concisely in the top level comment for each of these comments and the code already reads well