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::{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///   case-insensitive 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 (case-insensitive),
153    /// 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 produce an informational notice.
159    pub(crate) async fn sync_jwt_groups(
160        &mut self,
161        member_id: RoleId,
162        groups: &[String],
163        notices: &mut Vec<AdapterNotice>,
164    ) -> Result<(), AdapterError> {
165        let role_map = self.catalog().roles_by_lowercase_name();
166
167        // Resolve group names to role IDs (case-insensitive).
168        let mut target_role_ids = BTreeSet::new();
169        for group in groups {
170            // Filter out reserved role names (mz_/pg_ prefixes, PUBLIC).
171            if catalog::is_reserved_role_name(group) {
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 role_map.get(&group.to_lowercase()).copied() {
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                    info!(
199                        group = group.as_str(),
200                        "OIDC group has no matching Materialize role, skipping"
201                    );
202                    notices.push(AdapterNotice::OidcGroupSyncUnmatchedGroup {
203                        group: group.clone(),
204                    });
205                }
206            }
207        }
208
209        // Get the user's current memberships. Clone is needed to release the
210        // immutable catalog borrow before the mutable catalog_transact call below.
211        // This is cheap — role membership maps are typically small.
212        let current_membership = self.catalog().get_role(&member_id).membership.map.clone();
213
214        // Compute diff.
215        let diff = compute_group_sync_diff(member_id, &current_membership, &target_role_ids);
216
217        // Skip catalog_transact if no changes (common for reconnect with same groups).
218        if diff.grants.is_empty() && diff.revokes.is_empty() {
219            return Ok(());
220        }
221
222        // Execute ops: revoke first, then grant.
223        let mut ops = diff.revokes;
224        ops.extend(diff.grants);
225
226        self.catalog_transact(None, ops).await?;
227
228        Ok(())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::catalog::is_reserved_role_name;
236
237    fn user_id() -> RoleId {
238        RoleId::User(100)
239    }
240    fn role_a() -> RoleId {
241        RoleId::User(1)
242    }
243    fn role_b() -> RoleId {
244        RoleId::User(2)
245    }
246    fn role_c() -> RoleId {
247        RoleId::User(3)
248    }
249    fn admin_id() -> RoleId {
250        RoleId::User(99)
251    }
252
253    /// Extract the role IDs from grant ops for easy assertion.
254    fn grant_role_ids(diff: &GroupSyncDiff) -> BTreeSet<RoleId> {
255        diff.grants
256            .iter()
257            .map(|op| match op {
258                Op::GrantRole { role_id, .. } => *role_id,
259                _ => panic!("expected GrantRole op"),
260            })
261            .collect()
262    }
263
264    /// Extract the role IDs from revoke ops for easy assertion.
265    fn revoke_role_ids(diff: &GroupSyncDiff) -> BTreeSet<RoleId> {
266        diff.revokes
267            .iter()
268            .map(|op| match op {
269                Op::RevokeRole { role_id, .. } => *role_id,
270                _ => panic!("expected RevokeRole op"),
271            })
272            .collect()
273    }
274
275    /// Verify all grant ops use the sentinel grantor and correct member.
276    fn assert_grants_well_formed(diff: &GroupSyncDiff, expected_member: RoleId) {
277        for op in &diff.grants {
278            match op {
279                Op::GrantRole {
280                    member_id,
281                    grantor_id,
282                    ..
283                } => {
284                    assert_eq!(*member_id, expected_member);
285                    assert_eq!(*grantor_id, MZ_JWT_SYNC_ROLE_ID);
286                }
287                _ => panic!("expected GrantRole op"),
288            }
289        }
290    }
291
292    /// Verify all revoke ops use the sentinel grantor and correct member.
293    fn assert_revokes_well_formed(diff: &GroupSyncDiff, expected_member: RoleId) {
294        for op in &diff.revokes {
295            match op {
296                Op::RevokeRole {
297                    member_id,
298                    grantor_id,
299                    ..
300                } => {
301                    assert_eq!(*member_id, expected_member);
302                    assert_eq!(*grantor_id, MZ_JWT_SYNC_ROLE_ID);
303                }
304                _ => panic!("expected RevokeRole op"),
305            }
306        }
307    }
308
309    #[mz_ore::test]
310    fn test_first_login_grants_all() {
311        let current = BTreeMap::new();
312        let target = BTreeSet::from([role_a(), role_b()]);
313        let diff = compute_group_sync_diff(user_id(), &current, &target);
314
315        assert_eq!(diff.grants.len(), 2);
316        assert_eq!(diff.revokes.len(), 0);
317        assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_a(), role_b()]));
318        assert_grants_well_formed(&diff, user_id());
319    }
320
321    #[mz_ore::test]
322    fn test_no_change_is_noop() {
323        let current = BTreeMap::from([
324            (role_a(), MZ_JWT_SYNC_ROLE_ID),
325            (role_b(), MZ_JWT_SYNC_ROLE_ID),
326        ]);
327        let target = BTreeSet::from([role_a(), role_b()]);
328        let diff = compute_group_sync_diff(user_id(), &current, &target);
329
330        assert_eq!(diff.grants.len(), 0);
331        assert_eq!(diff.revokes.len(), 0);
332    }
333
334    #[mz_ore::test]
335    fn test_revoke_removed_groups() {
336        let current = BTreeMap::from([
337            (role_a(), MZ_JWT_SYNC_ROLE_ID),
338            (role_b(), MZ_JWT_SYNC_ROLE_ID),
339            (role_c(), MZ_JWT_SYNC_ROLE_ID),
340        ]);
341        let target = BTreeSet::from([role_a()]);
342        let diff = compute_group_sync_diff(user_id(), &current, &target);
343
344        assert_eq!(diff.grants.len(), 0);
345        assert_eq!(diff.revokes.len(), 2);
346        assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_b(), role_c()]));
347        assert_revokes_well_formed(&diff, user_id());
348    }
349
350    #[mz_ore::test]
351    fn test_manual_grants_untouched() {
352        // Manual grant for A (by admin), sync grant for B.
353        // Target: A, C.
354        // Expected: grant C (A is manual, skip), revoke B (not in target).
355        let current = BTreeMap::from([(role_a(), admin_id()), (role_b(), MZ_JWT_SYNC_ROLE_ID)]);
356        let target = BTreeSet::from([role_a(), role_c()]);
357        let diff = compute_group_sync_diff(user_id(), &current, &target);
358
359        assert_eq!(diff.grants.len(), 1);
360        assert_eq!(diff.revokes.len(), 1);
361        assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_c()]));
362        assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_b()]));
363    }
364
365    #[mz_ore::test]
366    fn test_empty_target_revokes_all_sync() {
367        // Sync-granted A, B. Manual C. Target: empty.
368        // Expected: revoke A and B, keep C.
369        let current = BTreeMap::from([
370            (role_a(), MZ_JWT_SYNC_ROLE_ID),
371            (role_b(), MZ_JWT_SYNC_ROLE_ID),
372            (role_c(), admin_id()),
373        ]);
374        let target = BTreeSet::new();
375        let diff = compute_group_sync_diff(user_id(), &current, &target);
376
377        assert_eq!(diff.grants.len(), 0);
378        assert_eq!(diff.revokes.len(), 2);
379        assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_a(), role_b()]));
380    }
381
382    #[mz_ore::test]
383    fn test_mixed_grant_and_revoke() {
384        // Sync-granted A. Target: B.
385        // Expected: grant B, revoke A.
386        let current = BTreeMap::from([(role_a(), MZ_JWT_SYNC_ROLE_ID)]);
387        let target = BTreeSet::from([role_b()]);
388        let diff = compute_group_sync_diff(user_id(), &current, &target);
389
390        assert_eq!(diff.grants.len(), 1);
391        assert_eq!(diff.revokes.len(), 1);
392        assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_b()]));
393        assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_a()]));
394    }
395
396    #[mz_ore::test]
397    fn test_empty_current_empty_target() {
398        let current = BTreeMap::new();
399        let target = BTreeSet::new();
400        let diff = compute_group_sync_diff(user_id(), &current, &target);
401
402        assert_eq!(diff.grants.len(), 0);
403        assert_eq!(diff.revokes.len(), 0);
404    }
405
406    #[mz_ore::test]
407    fn test_all_manual_grants_no_revokes() {
408        // All current memberships are manual, none sync-granted.
409        // Target has a new role. Manual ones should not be revoked.
410        let current = BTreeMap::from([(role_a(), admin_id()), (role_b(), admin_id())]);
411        let target = BTreeSet::from([role_c()]);
412        let diff = compute_group_sync_diff(user_id(), &current, &target);
413
414        assert_eq!(diff.grants.len(), 1);
415        assert_eq!(diff.revokes.len(), 0);
416        assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_c()]));
417    }
418
419    #[mz_ore::test]
420    fn test_target_overlaps_both_manual_and_sync() {
421        // A is manual, B is sync, C is sync. Target: A, B, D.
422        // Expected: grant D (A manual=skip, B sync=already there), revoke C.
423        let role_d = RoleId::User(4);
424        let current = BTreeMap::from([
425            (role_a(), admin_id()),
426            (role_b(), MZ_JWT_SYNC_ROLE_ID),
427            (role_c(), MZ_JWT_SYNC_ROLE_ID),
428        ]);
429        let target = BTreeSet::from([role_a(), role_b(), role_d]);
430        let diff = compute_group_sync_diff(user_id(), &current, &target);
431
432        assert_eq!(diff.grants.len(), 1);
433        assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_d]));
434        assert_eq!(diff.revokes.len(), 1);
435        assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_c()]));
436    }
437
438    // --- Reserved role name filtering tests ---
439    // These verify the contract that `is_reserved_role_name` correctly
440    // identifies names that sync_jwt_groups should filter out.
441
442    #[mz_ore::test]
443    fn test_reserved_role_mz_prefix() {
444        assert!(is_reserved_role_name("mz_system"));
445        assert!(is_reserved_role_name("mz_introspection"));
446        assert!(is_reserved_role_name("mz_jwt_sync"));
447        assert!(is_reserved_role_name("mz_anything"));
448    }
449
450    #[mz_ore::test]
451    fn test_reserved_role_pg_prefix() {
452        assert!(is_reserved_role_name("pg_monitor"));
453        assert!(is_reserved_role_name("pg_read_all_data"));
454    }
455
456    #[mz_ore::test]
457    fn test_reserved_role_public() {
458        assert!(is_reserved_role_name("PUBLIC"));
459    }
460
461    #[mz_ore::test]
462    fn test_normal_role_names_not_reserved() {
463        assert!(!is_reserved_role_name("analytics"));
464        assert!(!is_reserved_role_name("platform_eng"));
465        assert!(!is_reserved_role_name("admin"));
466        assert!(!is_reserved_role_name("data_eng"));
467        // Prefix must be exact — "mzz_foo" or "pga_foo" are not reserved.
468        assert!(!is_reserved_role_name("mzz_custom"));
469        assert!(!is_reserved_role_name("pga_custom"));
470    }
471
472    // --- Diff tests for edge cases related to reserved role filtering ---
473    // When reserved roles are filtered out before reaching compute_group_sync_diff,
474    // the target set is effectively reduced. These tests verify the diff function
475    // handles the resulting scenarios correctly.
476
477    #[mz_ore::test]
478    fn test_all_reserved_filtered_results_in_empty_target() {
479        // If all groups are reserved and filtered, target is empty.
480        // Existing sync-granted roles should be revoked.
481        let current = BTreeMap::from([
482            (role_a(), MZ_JWT_SYNC_ROLE_ID),
483            (role_b(), MZ_JWT_SYNC_ROLE_ID),
484        ]);
485        let target = BTreeSet::new(); // empty after filtering
486        let diff = compute_group_sync_diff(user_id(), &current, &target);
487
488        assert_eq!(diff.grants.len(), 0);
489        assert_eq!(diff.revokes.len(), 2);
490        assert_eq!(revoke_role_ids(&diff), BTreeSet::from([role_a(), role_b()]));
491    }
492
493    #[mz_ore::test]
494    fn test_mixed_reserved_and_valid_after_filtering() {
495        // If some groups are reserved (filtered) and some are valid,
496        // only valid ones appear in target. This is the same as a
497        // partial target — only valid roles are granted.
498        let current = BTreeMap::new();
499        let target = BTreeSet::from([role_a()]); // role_b was reserved, filtered out
500        let diff = compute_group_sync_diff(user_id(), &current, &target);
501
502        assert_eq!(diff.grants.len(), 1);
503        assert_eq!(diff.revokes.len(), 0);
504        assert_eq!(grant_role_ids(&diff), BTreeSet::from([role_a()]));
505    }
506}