1use std::collections::{BTreeMap, BTreeSet};
18
19use mz_adapter_types::dyncfgs::{OIDC_GROUP_ROLE_SYNC_ENABLED, OIDC_GROUP_ROLE_SYNC_STRICT};
20use mz_repr::role_id::RoleId;
21use mz_sql::session::user::MZ_JWT_SYNC_ROLE_ID;
22use tokio::sync::mpsc;
23use tracing::{debug, info, warn};
24
25use crate::AdapterError;
26use crate::catalog::{self, Op};
27use crate::coord::Coordinator;
28use crate::notice::AdapterNotice;
29
30#[derive(Debug, Clone)]
32pub struct GroupSyncDiff {
33 pub grants: Vec<Op>,
35 pub revokes: Vec<Op>,
37}
38
39pub fn compute_group_sync_diff(
56 member_id: RoleId,
57 current_membership: &BTreeMap<RoleId, RoleId>,
58 target_role_ids: &BTreeSet<RoleId>,
59) -> GroupSyncDiff {
60 let mut sync_granted: BTreeSet<RoleId> = BTreeSet::new();
62 let mut manual_granted: BTreeSet<RoleId> = BTreeSet::new();
63
64 for (role_id, grantor_id) in current_membership {
65 if *grantor_id == MZ_JWT_SYNC_ROLE_ID {
66 sync_granted.insert(*role_id);
67 } else {
68 manual_granted.insert(*role_id);
69 }
70 }
71
72 let grants: Vec<Op> = target_role_ids
74 .iter()
75 .filter(|r| !sync_granted.contains(r) && !manual_granted.contains(r))
76 .map(|&role_id| Op::GrantRole {
77 role_id,
78 member_id,
79 grantor_id: MZ_JWT_SYNC_ROLE_ID,
80 })
81 .collect();
82
83 let revokes: Vec<Op> = sync_granted
85 .iter()
86 .filter(|r| !target_role_ids.contains(r))
87 .map(|&role_id| Op::RevokeRole {
88 role_id,
89 member_id,
90 grantor_id: MZ_JWT_SYNC_ROLE_ID,
91 })
92 .collect();
93
94 GroupSyncDiff { grants, revokes }
95}
96
97impl Coordinator {
98 pub(crate) async fn maybe_sync_jwt_groups(
109 &mut self,
110 member_id: RoleId,
111 groups: Option<&[String]>,
112 notice_tx: &mpsc::UnboundedSender<AdapterNotice>,
113 ) -> Result<(), AdapterError> {
114 let groups = match groups {
115 Some(g) => g,
116 None => return Ok(()),
117 };
118
119 let dyncfgs = self.catalog().system_config().dyncfgs();
120 let sync_enabled = OIDC_GROUP_ROLE_SYNC_ENABLED.get(dyncfgs);
121 let strict = OIDC_GROUP_ROLE_SYNC_STRICT.get(dyncfgs);
122
123 if !sync_enabled {
124 return Ok(());
125 }
126
127 let mut notices = Vec::new();
128 match self.sync_jwt_groups(member_id, groups, &mut notices).await {
129 Ok(()) => {}
130 Err(e) => {
131 if strict {
132 return Err(AdapterError::OidcGroupSyncFailed(e.to_string()));
133 } else {
134 warn!(
135 error = %e,
136 "OIDC group sync failed, proceeding with login (fail-open mode)"
137 );
138 notices.push(AdapterNotice::OidcGroupSyncError {
139 message: e.to_string(),
140 });
141 }
142 }
143 }
144 for notice in notices {
145 let _ = notice_tx.send(notice);
146 }
147 Ok(())
148 }
149
150 pub(crate) async fn sync_jwt_groups(
161 &mut self,
162 member_id: RoleId,
163 groups: &[String],
164 notices: &mut Vec<AdapterNotice>,
165 ) -> Result<(), AdapterError> {
166 let mut target_role_ids = BTreeSet::new();
168 for group in groups {
169 if catalog::is_reserved_role_name(&group.to_lowercase()) {
172 warn!(
173 group = group.as_str(),
174 "OIDC group maps to reserved role name, skipping"
175 );
176 notices.push(AdapterNotice::OidcGroupSyncReservedRole {
177 group: group.clone(),
178 });
179 continue;
180 }
181
182 match self.catalog().try_get_role_by_name(group) {
183 Some(role) => {
184 if role.id == member_id {
189 info!(
190 group = group.as_str(),
191 "OIDC group maps to the user's own role, skipping"
192 );
193 continue;
194 }
195 target_role_ids.insert(role.id);
196 }
197 None => {
198 debug!(
199 group = group.as_str(),
200 "OIDC group has no matching Materialize role, skipping"
201 );
202 }
203 }
204 }
205
206 let current_membership = self.catalog().get_role(&member_id).membership.map.clone();
210
211 let diff = compute_group_sync_diff(member_id, ¤t_membership, &target_role_ids);
213
214 if diff.grants.is_empty() && diff.revokes.is_empty() {
216 return Ok(());
217 }
218
219 let mut ops = diff.revokes;
221 ops.extend(diff.grants);
222
223 self.catalog_transact(None, ops).await?;
224
225 Ok(())
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::catalog::is_reserved_role_name;
233
234 fn user_id() -> RoleId {
235 RoleId::User(100)
236 }
237 fn role_a() -> RoleId {
238 RoleId::User(1)
239 }
240 fn role_b() -> RoleId {
241 RoleId::User(2)
242 }
243 fn role_c() -> RoleId {
244 RoleId::User(3)
245 }
246 fn admin_id() -> RoleId {
247 RoleId::User(99)
248 }
249
250 fn grant_role_ids(diff: &GroupSyncDiff) -> BTreeSet<RoleId> {
252 diff.grants
253 .iter()
254 .map(|op| match op {
255 Op::GrantRole { role_id, .. } => *role_id,
256 _ => panic!("expected GrantRole op"),
257 })
258 .collect()
259 }
260
261 fn revoke_role_ids(diff: &GroupSyncDiff) -> BTreeSet<RoleId> {
263 diff.revokes
264 .iter()
265 .map(|op| match op {
266 Op::RevokeRole { role_id, .. } => *role_id,
267 _ => panic!("expected RevokeRole op"),
268 })
269 .collect()
270 }
271
272 fn assert_grants_well_formed(diff: &GroupSyncDiff, expected_member: RoleId) {
274 for op in &diff.grants {
275 match op {
276 Op::GrantRole {
277 member_id,
278 grantor_id,
279 ..
280 } => {
281 assert_eq!(*member_id, expected_member);
282 assert_eq!(*grantor_id, MZ_JWT_SYNC_ROLE_ID);
283 }
284 _ => panic!("expected GrantRole op"),
285 }
286 }
287 }
288
289 fn assert_revokes_well_formed(diff: &GroupSyncDiff, expected_member: RoleId) {
291 for op in &diff.revokes {
292 match op {
293 Op::RevokeRole {
294 member_id,
295 grantor_id,
296 ..
297 } => {
298 assert_eq!(*member_id, expected_member);
299 assert_eq!(*grantor_id, MZ_JWT_SYNC_ROLE_ID);
300 }
301 _ => panic!("expected RevokeRole op"),
302 }
303 }
304 }
305
306 #[mz_ore::test]
307 fn test_first_login_grants_all() {
308 let current = BTreeMap::new();
309 let target = BTreeSet::from([role_a(), role_b()]);
310 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
311
312 assert_eq!(diff.grants.len(), 2);
313 assert_eq!(diff.revokes.len(), 0);
314 assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_a(), role_b()]));
315 assert_grants_well_formed(&diff, user_id());
316 }
317
318 #[mz_ore::test]
319 fn test_no_change_is_noop() {
320 let current = BTreeMap::from([
321 (role_a(), MZ_JWT_SYNC_ROLE_ID),
322 (role_b(), MZ_JWT_SYNC_ROLE_ID),
323 ]);
324 let target = BTreeSet::from([role_a(), role_b()]);
325 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
326
327 assert_eq!(diff.grants.len(), 0);
328 assert_eq!(diff.revokes.len(), 0);
329 }
330
331 #[mz_ore::test]
332 fn test_revoke_removed_groups() {
333 let current = BTreeMap::from([
334 (role_a(), MZ_JWT_SYNC_ROLE_ID),
335 (role_b(), MZ_JWT_SYNC_ROLE_ID),
336 (role_c(), MZ_JWT_SYNC_ROLE_ID),
337 ]);
338 let target = BTreeSet::from([role_a()]);
339 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
340
341 assert_eq!(diff.grants.len(), 0);
342 assert_eq!(diff.revokes.len(), 2);
343 assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_b(), role_c()]));
344 assert_revokes_well_formed(&diff, user_id());
345 }
346
347 #[mz_ore::test]
348 fn test_manual_grants_untouched() {
349 let current = BTreeMap::from([(role_a(), admin_id()), (role_b(), MZ_JWT_SYNC_ROLE_ID)]);
353 let target = BTreeSet::from([role_a(), role_c()]);
354 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
355
356 assert_eq!(diff.grants.len(), 1);
357 assert_eq!(diff.revokes.len(), 1);
358 assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_c()]));
359 assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_b()]));
360 }
361
362 #[mz_ore::test]
363 fn test_empty_target_revokes_all_sync() {
364 let current = BTreeMap::from([
367 (role_a(), MZ_JWT_SYNC_ROLE_ID),
368 (role_b(), MZ_JWT_SYNC_ROLE_ID),
369 (role_c(), admin_id()),
370 ]);
371 let target = BTreeSet::new();
372 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
373
374 assert_eq!(diff.grants.len(), 0);
375 assert_eq!(diff.revokes.len(), 2);
376 assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_a(), role_b()]));
377 }
378
379 #[mz_ore::test]
380 fn test_mixed_grant_and_revoke() {
381 let current = BTreeMap::from([(role_a(), MZ_JWT_SYNC_ROLE_ID)]);
384 let target = BTreeSet::from([role_b()]);
385 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
386
387 assert_eq!(diff.grants.len(), 1);
388 assert_eq!(diff.revokes.len(), 1);
389 assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_b()]));
390 assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_a()]));
391 }
392
393 #[mz_ore::test]
394 fn test_empty_current_empty_target() {
395 let current = BTreeMap::new();
396 let target = BTreeSet::new();
397 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
398
399 assert_eq!(diff.grants.len(), 0);
400 assert_eq!(diff.revokes.len(), 0);
401 }
402
403 #[mz_ore::test]
404 fn test_all_manual_grants_no_revokes() {
405 let current = BTreeMap::from([(role_a(), admin_id()), (role_b(), admin_id())]);
408 let target = BTreeSet::from([role_c()]);
409 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
410
411 assert_eq!(diff.grants.len(), 1);
412 assert_eq!(diff.revokes.len(), 0);
413 assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_c()]));
414 }
415
416 #[mz_ore::test]
417 fn test_target_overlaps_both_manual_and_sync() {
418 let role_d = RoleId::User(4);
421 let current = BTreeMap::from([
422 (role_a(), admin_id()),
423 (role_b(), MZ_JWT_SYNC_ROLE_ID),
424 (role_c(), MZ_JWT_SYNC_ROLE_ID),
425 ]);
426 let target = BTreeSet::from([role_a(), role_b(), role_d]);
427 let diff = compute_group_sync_diff(user_id(), ¤t, &target);
428
429 assert_eq!(diff.grants.len(), 1);
430 assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_d]));
431 assert_eq!(diff.revokes.len(), 1);
432 assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_c()]));
433 }
434
435 #[mz_ore::test]
440 fn test_reserved_role_mz_prefix() {
441 assert!(is_reserved_role_name("mz_system"));
442 assert!(is_reserved_role_name("mz_introspection"));
443 assert!(is_reserved_role_name("mz_jwt_sync"));
444 assert!(is_reserved_role_name("mz_anything"));
445 }
446
447 #[mz_ore::test]
448 fn test_reserved_role_pg_prefix() {
449 assert!(is_reserved_role_name("pg_monitor"));
450 assert!(is_reserved_role_name("pg_read_all_data"));
451 }
452
453 #[mz_ore::test]
454 fn test_reserved_role_public() {
455 assert!(is_reserved_role_name("PUBLIC"));
456 }
457
458 #[mz_ore::test]
459 fn test_reserved_role_specification_names() {
460 assert!(is_reserved_role_name("current_user"));
461 assert!(is_reserved_role_name("current_role"));
462 assert!(is_reserved_role_name("session_user"));
463 assert!(is_reserved_role_name("user"));
464 assert!(is_reserved_role_name("none"));
465 assert!(!is_reserved_role_name("CURRENT_USER"));
468 assert!(!is_reserved_role_name("None"));
469 }
470
471 #[mz_ore::test]
472 fn test_normal_role_names_not_reserved() {
473 assert!(!is_reserved_role_name("analytics"));
474 assert!(!is_reserved_role_name("platform_eng"));
475 assert!(!is_reserved_role_name("admin"));
476 assert!(!is_reserved_role_name("data_eng"));
477 assert!(!is_reserved_role_name("mzz_custom"));
479 assert!(!is_reserved_role_name("pga_custom"));
480 }
481
482 #[mz_ore::test]
488 fn test_all_reserved_filtered_results_in_empty_target() {
489 let current = BTreeMap::from([
492 (role_a(), MZ_JWT_SYNC_ROLE_ID),
493 (role_b(), MZ_JWT_SYNC_ROLE_ID),
494 ]);
495 let target = BTreeSet::new(); let diff = compute_group_sync_diff(user_id(), ¤t, &target);
497
498 assert_eq!(diff.grants.len(), 0);
499 assert_eq!(diff.revokes.len(), 2);
500 assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_a(), role_b()]));
501 }
502
503 #[mz_ore::test]
504 fn test_mixed_reserved_and_valid_after_filtering() {
505 let current = BTreeMap::new();
509 let target = BTreeSet::from([role_a()]); let diff = compute_group_sync_diff(user_id(), ¤t, &target);
511
512 assert_eq!(diff.grants.len(), 1);
513 assert_eq!(diff.revokes.len(), 0);
514 assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_a()]));
515 }
516}