Skip to main content

mz_adapter/coord/
group_sync.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file at the root of this repository.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! JWT group-to-role membership sync logic.
11//!
12//! This module computes the diff between a user's current role memberships
13//! and their JWT group claims, producing `Op::GrantRole` and `Op::RevokeRole`
14//! operations. Only memberships granted by the `MZ_JWT_SYNC_ROLE_ID` sentinel
15//! are managed; manually-granted memberships are never touched.
16
17use 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/// Result of computing the group-to-role membership sync diff.
31#[derive(Debug, Clone)]
32pub struct GroupSyncDiff {
33    /// Roles to grant to the user (with sentinel grantor).
34    pub grants: Vec<Op>,
35    /// Roles to revoke from the user (with sentinel grantor).
36    pub revokes: Vec<Op>,
37}
38
39/// Computes the grant/revoke operations needed to sync a user's role
40/// memberships with their JWT group claims.
41///
42/// # Arguments
43/// - `member_id`: The user's role ID.
44/// - `current_membership`: The user's current `RoleMembership.map`
45///   (role_id → grantor_id).
46/// - `target_role_ids`: Role IDs resolved from the JWT group names via
47///   exact (case-sensitive) catalog lookup.
48///
49/// # Semantics
50/// - Only roles granted by the JWT sync sentinel (`MZ_JWT_SYNC_ROLE_ID`)
51///   are managed by this function.
52/// - Manually-granted roles (grantor != sentinel) are never revoked.
53/// - If a target role is already manually granted, it is skipped — the
54///   manual grant takes precedence and we don't overwrite the grantor.
55pub fn compute_group_sync_diff(
56    member_id: RoleId,
57    current_membership: &BTreeMap<RoleId, RoleId>,
58    target_role_ids: &BTreeSet<RoleId>,
59) -> GroupSyncDiff {
60    // Partition current memberships into sync-managed vs manually-granted.
61    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    // Roles to grant: in target, not already sync-granted, not manually-granted.
73    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    // Roles to revoke: sync-granted but no longer in target.
84    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    /// Top-level entry point for JWT group-to-role sync during connection startup.
99    ///
100    /// Checks whether sync is enabled and whether the user has group claims,
101    /// then delegates to [`Self::sync_jwt_groups`]. Handles strict vs fail-open
102    /// error semantics and delivers notices to the client via `notice_tx`.
103    ///
104    /// - `groups == None` (claim absent) → skip sync entirely, preserving current state.
105    /// - `groups == Some([])` (empty claim) → revoke all sync-granted roles.
106    /// - Strict mode (`oidc_group_role_sync_strict`) → reject login on sync failure.
107    /// - Fail-open (default) → log warning, send notice, continue login.
108    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    /// Syncs the user's role memberships based on JWT group claims.
151    ///
152    /// Resolves group names to catalog role IDs via exact (case-sensitive)
153    /// lookup, computes the diff against current memberships, and executes
154    /// grant/revoke operations via `catalog_transact`.
155    ///
156    /// Groups that map to reserved role names (`mz_`/`pg_` prefixes) are
157    /// filtered out with a warning notice. Groups with no matching catalog
158    /// role are skipped silently: IdPs commonly include groups that have no
159    /// corresponding Materialize role, so this is expected.
160    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        // Resolve group names to role IDs (exact, case-sensitive match).
167        let mut target_role_ids = BTreeSet::new();
168        for group in groups {
169            // Filter out reserved role names (mz_/pg_ prefixes, PUBLIC).
170            // Check case-insensitively so "MZ_SYSTEM" is also blocked.
171            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                    // Skip if the group resolves to the user's own role. This
185                    // happens when an IdP echoes the username/email into the
186                    // groups claim. Granting a role to itself would trigger
187                    // the catalog's circular-membership guard.
188                    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        // Get the user's current memberships. Clone is needed to release the
207        // immutable catalog borrow before the mutable catalog_transact call below.
208        // This is cheap — role membership maps are typically small.
209        let current_membership = self.catalog().get_role(&member_id).membership.map.clone();
210
211        // Compute diff.
212        let diff = compute_group_sync_diff(member_id, &current_membership, &target_role_ids);
213
214        // Skip catalog_transact if no changes (common for reconnect with same groups).
215        if diff.grants.is_empty() && diff.revokes.is_empty() {
216            return Ok(());
217        }
218
219        // Execute ops: revoke first, then grant.
220        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    /// Extract the role IDs from grant ops for easy assertion.
251    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    /// Extract the role IDs from revoke ops for easy assertion.
262    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    /// Verify all grant ops use the sentinel grantor and correct member.
273    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    /// Verify all revoke ops use the sentinel grantor and correct member.
290    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(), &current, &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(), &current, &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(), &current, &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        // Manual grant for A (by admin), sync grant for B.
350        // Target: A, C.
351        // Expected: grant C (A is manual, skip), revoke B (not in target).
352        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(), &current, &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        // Sync-granted A, B. Manual C. Target: empty.
365        // Expected: revoke A and B, keep C.
366        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(), &current, &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        // Sync-granted A. Target: B.
382        // Expected: grant B, revoke A.
383        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(), &current, &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(), &current, &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        // All current memberships are manual, none sync-granted.
406        // Target has a new role. Manual ones should not be revoked.
407        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(), &current, &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        // A is manual, B is sync, C is sync. Target: A, B, D.
419        // Expected: grant D (A manual=skip, B sync=already there), revoke C.
420        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(), &current, &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    // --- Reserved role name filtering tests ---
436    // These verify the contract that `is_reserved_role_name` correctly
437    // identifies names that sync_jwt_groups should filter out.
438
439    #[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        // Only the lowercase spellings are reserved, matching what unquoted
466        // identifiers normalize to.
467        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        // Prefix must be exact — "mzz_foo" or "pga_foo" are not reserved.
478        assert!(!is_reserved_role_name("mzz_custom"));
479        assert!(!is_reserved_role_name("pga_custom"));
480    }
481
482    // --- Diff tests for edge cases related to reserved role filtering ---
483    // When reserved roles are filtered out before reaching compute_group_sync_diff,
484    // the target set is effectively reduced. These tests verify the diff function
485    // handles the resulting scenarios correctly.
486
487    #[mz_ore::test]
488    fn test_all_reserved_filtered_results_in_empty_target() {
489        // If all groups are reserved and filtered, target is empty.
490        // Existing sync-granted roles should be revoked.
491        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(); // empty after filtering
496        let diff = compute_group_sync_diff(user_id(), &current, &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        // If some groups are reserved (filtered) and some are valid,
506        // only valid ones appear in target. This is the same as a
507        // partial target — only valid roles are granted.
508        let current = BTreeMap::new();
509        let target = BTreeSet::from([role_a()]); // role_b was reserved, filtered out
510        let diff = compute_group_sync_diff(user_id(), &current, &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}