From 39f319a12134585974ce9d3e5429c3aa70008a86 Mon Sep 17 00:00:00 2001 From: mtabebe Date: Mon, 27 Apr 2026 09:48:25 -0400 Subject: [PATCH] [SSO] Add foundation for JWT group-to-role sync Add the building blocks for OIDC JWT group-to-role synchronization: mz_jwt_sync builtin sentinel role that marks role memberships managed by JWT sync, distinguishing them from manual grants. Three dyncfg system variables: - oidc_group_role_sync_enabled (bool, default false): feature gate - oidc_group_claim (string, default "groups"): JWT claim name - oidc_group_role_sync_strict (bool, default false): fail-closed mode - OidcClaims::groups() extraction method that normalizes group names (lowercase, deduplicate, sort) from JWT claims. Handles array-of-strings, single-string, and filters non-string values. Returns None for missing claims (skip sync) vs Some(vec![]) for empty claims (revoke all). No behavioral change --- .../generated/adapter-types/dyncfgs.md | 4 +- misc/python/materialize/mzcompose/__init__.py | 3 + .../materialize/parallel_workload/action.py | 3 + src/adapter-types/src/dyncfgs.rs | 26 ++ src/authenticator/src/oidc.rs | 293 +++++++++++++++++- src/catalog/src/builtin.rs | 15 +- .../tests/snapshots/debug__opened_trace.snap | 30 ++ .../snapshots/open__initial_snapshot.snap | 21 ++ src/pgrepr-consts/src/oid.rs | 1 + src/sql/src/session/user.rs | 4 + test/sqllogictest/id_reuse.slt | 2 + test/sqllogictest/oid.slt | 1 + test/sqllogictest/pg_catalog_roles.slt | 1 + test/sqllogictest/role.slt | 6 + test/sqllogictest/role_membership.slt | 3 +- 15 files changed, 407 insertions(+), 6 deletions(-) diff --git a/doc/developer/generated/adapter-types/dyncfgs.md b/doc/developer/generated/adapter-types/dyncfgs.md index df369938b0972..903a146d6872d 100644 --- a/doc/developer/generated/adapter-types/dyncfgs.md +++ b/doc/developer/generated/adapter-types/dyncfgs.md @@ -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. diff --git a/misc/python/materialize/mzcompose/__init__.py b/misc/python/materialize/mzcompose/__init__.py index 112e0ce36c58d..1d987f6a1ccd7 100644 --- a/misc/python/materialize/mzcompose/__init__.py +++ b/misc/python/materialize/mzcompose/__init__.py @@ -646,6 +646,9 @@ def get_default_system_parameters( "oidc_issuer", "oidc_audience", "oidc_authentication_claim", + "oidc_group_role_sync_enabled", + "oidc_group_claim", + "oidc_group_role_sync_strict", "console_oidc_client_id", "console_oidc_scopes", "enable_mcp_agent", diff --git a/misc/python/materialize/parallel_workload/action.py b/misc/python/materialize/parallel_workload/action.py index 556e959f90590..35137fac1815a 100644 --- a/misc/python/materialize/parallel_workload/action.py +++ b/misc/python/materialize/parallel_workload/action.py @@ -1794,6 +1794,9 @@ def __init__( "oidc_issuer", "oidc_audience", "oidc_authentication_claim", + "oidc_group_role_sync_enabled", + "oidc_group_claim", + "oidc_group_role_sync_strict", "console_oidc_client_id", "console_oidc_scopes", ] diff --git a/src/adapter-types/src/dyncfgs.rs b/src/adapter-types/src/dyncfgs.rs index 4ed2e5a0f4cf4..9dee9dccbac66 100644 --- a/src/adapter-types/src/dyncfgs.rs +++ b/src/adapter-types/src/dyncfgs.rs @@ -146,6 +146,29 @@ pub const OIDC_AUTHENTICATION_CLAIM: Config<&'static str> = Config::new( "OIDC authentication claim to use as username.", ); +/// Whether OIDC group-to-role sync is enabled. +/// When true, JWT group claims are used to sync role memberships on login. +pub const OIDC_GROUP_ROLE_SYNC_ENABLED: Config = Config::new( + "oidc_group_role_sync_enabled", + false, + "Enable OIDC JWT group-to-role membership sync on login.", +); + +/// The JWT claim name that contains group memberships. +pub const OIDC_GROUP_CLAIM: Config<&'static str> = Config::new( + "oidc_group_claim", + "groups", + "JWT claim name containing group memberships for role sync.", +); + +/// Whether to reject login when group sync fails (strict/fail-closed mode). +/// When false (default), sync failures are logged but login proceeds (fail-open). +pub const OIDC_GROUP_ROLE_SYNC_STRICT: Config = Config::new( + "oidc_group_role_sync_strict", + false, + "When true, reject login if OIDC group-to-role sync fails (fail-closed).", +); + pub const PERSIST_FAST_PATH_ORDER: Config = Config::new( "persist_fast_path_order", false, @@ -236,6 +259,9 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet { .add(&OIDC_ISSUER) .add(&OIDC_AUDIENCE) .add(&OIDC_AUTHENTICATION_CLAIM) + .add(&OIDC_GROUP_ROLE_SYNC_ENABLED) + .add(&OIDC_GROUP_CLAIM) + .add(&OIDC_GROUP_ROLE_SYNC_STRICT) .add(&PERSIST_FAST_PATH_ORDER) .add(&ENABLE_S3_TABLES_REGION_CHECK) .add(&ENABLE_MCP_AGENT) diff --git a/src/authenticator/src/oidc.rs b/src/authenticator/src/oidc.rs index d5748c55b6444..9705df04bae06 100644 --- a/src/authenticator/src/oidc.rs +++ b/src/authenticator/src/oidc.rs @@ -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> { + let value = self.unknown_claims.get(claim_name)?; + + let raw_groups: Vec = 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 = raw_groups + .into_iter() + .map(|g| g.trim().to_lowercase()) + .filter(|g| !g.is_empty()) + .collect::>() + .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() { + // "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 = (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(), + ]) + ); + } } diff --git a/src/catalog/src/builtin.rs b/src/catalog/src/builtin.rs index cf782cef5c57d..fae29b1c970d2 100644 --- a/src/catalog/src/builtin.rs +++ b/src/catalog/src/builtin.rs @@ -48,8 +48,9 @@ use mz_sql::catalog::{ }; use mz_sql::rbac; use mz_sql::session::user::{ - ANALYTICS_USER_NAME, MZ_ANALYTICS_ROLE_ID, MZ_MONITOR_REDACTED_ROLE_ID, MZ_MONITOR_ROLE_ID, - MZ_SUPPORT_ROLE_ID, MZ_SYSTEM_ROLE_ID, SUPPORT_USER_NAME, SYSTEM_USER_NAME, + ANALYTICS_USER_NAME, JWT_SYNC_ROLE_NAME, MZ_ANALYTICS_ROLE_ID, MZ_JWT_SYNC_ROLE_ID, + MZ_MONITOR_REDACTED_ROLE_ID, MZ_MONITOR_ROLE_ID, MZ_SUPPORT_ROLE_ID, MZ_SYSTEM_ROLE_ID, + SUPPORT_USER_NAME, SYSTEM_USER_NAME, }; use mz_storage_client::controller::IntrospectionType; use mz_storage_client::healthcheck::WALLCLOCK_GLOBAL_LAG_HISTOGRAM_RAW_DESC; @@ -14134,6 +14135,15 @@ pub const MZ_MONITOR_REDACTED: BuiltinRole = BuiltinRole { attributes: RoleAttributesRaw::new(), }; +/// Sentinel role used as the grantor for JWT group-sync-managed +/// role memberships. Never logged into directly. +pub const MZ_JWT_SYNC_ROLE: BuiltinRole = BuiltinRole { + id: MZ_JWT_SYNC_ROLE_ID, + name: JWT_SYNC_ROLE_NAME, + oid: oid::ROLE_MZ_JWT_SYNC_OID, + attributes: RoleAttributesRaw::new(), +}; + pub const MZ_SYSTEM_CLUSTER: BuiltinCluster = BuiltinCluster { name: SYSTEM_USER_NAME, owner_id: &MZ_SYSTEM_ROLE_ID, @@ -14738,6 +14748,7 @@ pub const BUILTIN_ROLES: &[&BuiltinRole] = &[ &MZ_ANALYTICS_ROLE, &MZ_MONITOR_ROLE, &MZ_MONITOR_REDACTED, + &MZ_JWT_SYNC_ROLE, ]; pub const BUILTIN_CLUSTERS: &[&BuiltinCluster] = &[ &MZ_SYSTEM_CLUSTER, diff --git a/src/catalog/tests/snapshots/debug__opened_trace.snap b/src/catalog/tests/snapshots/debug__opened_trace.snap index 0d9d3df9296bb..5a23178f79b59 100644 --- a/src/catalog/tests/snapshots/debug__opened_trace.snap +++ b/src/catalog/tests/snapshots/debug__opened_trace.snap @@ -1,5 +1,6 @@ --- source: src/catalog/tests/debug.rs +assertion_line: 235 expression: test_trace --- Trace { @@ -1002,6 +1003,35 @@ Trace { 1, ), ), + ( + ( + RoleKey { + id: System( + 4, + ), + }, + RoleValue { + name: "mz_jwt_sync", + attributes: RoleAttributes { + inherit: true, + superuser: None, + login: None, + auto_provision_source: None, + }, + membership: RoleMembership { + map: [], + }, + vars: RoleVars { + entries: [], + }, + oid: 17076, + }, + ), + 2, + Overflowing( + 1, + ), + ), ( ( RoleKey { diff --git a/src/catalog/tests/snapshots/open__initial_snapshot.snap b/src/catalog/tests/snapshots/open__initial_snapshot.snap index f7ba089b9a52c..7f7261bde5aec 100644 --- a/src/catalog/tests/snapshots/open__initial_snapshot.snap +++ b/src/catalog/tests/snapshots/open__initial_snapshot.snap @@ -1,5 +1,6 @@ --- source: src/catalog/tests/open.rs +assertion_line: 518 expression: test_snapshot --- Snapshot { @@ -480,6 +481,26 @@ Snapshot { }, oid: 16984, }, + RoleKey { + id: System( + 4, + ), + }: RoleValue { + name: "mz_jwt_sync", + attributes: RoleAttributes { + inherit: true, + superuser: None, + login: None, + auto_provision_source: None, + }, + membership: RoleMembership { + map: [], + }, + vars: RoleVars { + entries: [], + }, + oid: 17076, + }, RoleKey { id: Public, }: RoleValue { diff --git a/src/pgrepr-consts/src/oid.rs b/src/pgrepr-consts/src/oid.rs index 42e86996d3526..b15d916398ed2 100644 --- a/src/pgrepr-consts/src/oid.rs +++ b/src/pgrepr-consts/src/oid.rs @@ -793,3 +793,4 @@ pub const VIEW_MZ_BUILTIN_MATERIALIZED_VIEWS_OID: u32 = 17072; pub const FUNC_PARSE_CATALOG_CREATE_SQL_OID: u32 = 17073; pub const FUNC_REDACT_SQL_OID: u32 = 17074; pub const FUNC_REPEAT_ROW_NON_NEGATIVE_OID: u32 = 17075; +pub const ROLE_MZ_JWT_SYNC_OID: u32 = 17076; diff --git a/src/sql/src/session/user.rs b/src/sql/src/session/user.rs index 523f3d274551b..3e8363db73cc0 100644 --- a/src/sql/src/session/user.rs +++ b/src/sql/src/session/user.rs @@ -152,6 +152,10 @@ pub enum UserKind { pub const MZ_SYSTEM_ROLE_ID: RoleId = RoleId::System(1); pub const MZ_SUPPORT_ROLE_ID: RoleId = RoleId::System(2); pub const MZ_ANALYTICS_ROLE_ID: RoleId = RoleId::System(3); +/// Sentinel role ID for JWT group-sync-managed role memberships. +/// Not a login role — exists only to distinguish sync grants from manual grants. +pub const MZ_JWT_SYNC_ROLE_ID: RoleId = RoleId::System(4); +pub const JWT_SYNC_ROLE_NAME: &str = "mz_jwt_sync"; pub const MZ_MONITOR_ROLE_ID: RoleId = RoleId::Predefined(1); pub const MZ_MONITOR_REDACTED_ROLE_ID: RoleId = RoleId::Predefined(2); diff --git a/test/sqllogictest/id_reuse.slt b/test/sqllogictest/id_reuse.slt index e8c88c785c0a1..8f259bfb1ce84 100644 --- a/test/sqllogictest/id_reuse.slt +++ b/test/sqllogictest/id_reuse.slt @@ -84,6 +84,7 @@ g2 mz_monitor_redacted s1 mz_system s2 mz_support s3 mz_analytics +s4 mz_jwt_sync u1 materialize u2 foo @@ -104,6 +105,7 @@ g2 mz_monitor_redacted s1 mz_system s2 mz_support s3 mz_analytics +s4 mz_jwt_sync u1 materialize u3 bar diff --git a/test/sqllogictest/oid.slt b/test/sqllogictest/oid.slt index 5026d0c0c537d..052d210d1c891 100644 --- a/test/sqllogictest/oid.slt +++ b/test/sqllogictest/oid.slt @@ -91,6 +91,7 @@ SELECT oid, name FROM mz_roles WHERE id LIKE 's%' OR id LIKE 'g%' ORDER BY oid 16663 mz_monitor 16664 mz_monitor_redacted 16984 mz_analytics +17076 mz_jwt_sync # Only look at OIDs less than 20000 so that we don't consider system objects allocated at run time. query TT diff --git a/test/sqllogictest/pg_catalog_roles.slt b/test/sqllogictest/pg_catalog_roles.slt index 94a7c9e60f6e1..cdadc99fd8e54 100644 --- a/test/sqllogictest/pg_catalog_roles.slt +++ b/test/sqllogictest/pg_catalog_roles.slt @@ -17,6 +17,7 @@ SELECT rolname FROM pg_roles ORDER BY oid ---- materialize mz_analytics +mz_jwt_sync mz_monitor mz_monitor_redacted mz_support diff --git a/test/sqllogictest/role.slt b/test/sqllogictest/role.slt index 64cdff5b1e1f1..88b0af5b6889d 100644 --- a/test/sqllogictest/role.slt +++ b/test/sqllogictest/role.slt @@ -24,6 +24,7 @@ SELECT id, name, inherit FROM mz_roles WHERE id LIKE 's%' OR id LIKE 'g%' s1 mz_system true s2 mz_support true s3 mz_analytics true +s4 mz_jwt_sync true g1 mz_monitor true g2 mz_monitor_redacted true @@ -90,6 +91,7 @@ SELECT name, inherit FROM mz_roles ---- materialize true mz_analytics true +mz_jwt_sync true mz_monitor true mz_monitor_redacted true mz_support true @@ -106,6 +108,7 @@ SELECT name FROM mz_roles ---- materialize mz_analytics +mz_jwt_sync mz_monitor mz_monitor_redacted mz_support @@ -121,6 +124,7 @@ SELECT name FROM mz_roles ---- materialize mz_analytics +mz_jwt_sync mz_monitor mz_monitor_redacted mz_support @@ -135,6 +139,7 @@ SELECT name FROM mz_roles ---- materialize mz_analytics +mz_jwt_sync mz_monitor mz_monitor_redacted mz_support @@ -152,6 +157,7 @@ mz_monitor mz_support materialize mz_analytics +mz_jwt_sync mz_monitor_redacted statement ok diff --git a/test/sqllogictest/role_membership.slt b/test/sqllogictest/role_membership.slt index 8e1992c733989..bf8faccb17fd7 100644 --- a/test/sqllogictest/role_membership.slt +++ b/test/sqllogictest/role_membership.slt @@ -918,8 +918,9 @@ mz_monitor mz_support materialize mz_analytics +mz_jwt_sync mz_monitor_redacted -COMPLETE 10 +COMPLETE 11 statement ok DROP ROLE r1, r2, r3, r4