From 9914e30877a176c61fe55ef86b767896d2a61f0c Mon Sep 17 00:00:00 2001 From: Ubiratan Soares Date: Thu, 23 Apr 2026 12:25:20 +0200 Subject: [PATCH] gws: add diff for google accounts --- rust_team_data/src/v1.rs | 11 ++ src/main.rs | 8 +- src/schema.rs | 24 ++- src/static_api.rs | 17 +- src/sync/github/tests/test_utils.rs | 1 + src/sync/gws/api.rs | 41 +++++ src/sync/gws/mod.rs | 275 ++++++++++++++++++++++++++++ src/sync/mod.rs | 4 + 8 files changed, 369 insertions(+), 12 deletions(-) create mode 100644 src/sync/gws/api.rs create mode 100644 src/sync/gws/mod.rs diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index 92b498232..32953df86 100644 --- a/rust_team_data/src/v1.rs +++ b/rust_team_data/src/v1.rs @@ -26,6 +26,15 @@ pub struct Team { pub github: Option, pub website_data: Option, pub roles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub google_workspace_saml_group: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GoogleWorkspace { + pub first_name: String, + pub last_name: String, + pub account_handle: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -36,6 +45,8 @@ pub struct TeamMember { pub is_lead: bool, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub roles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub google_workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/main.rs b/src/main.rs index 068034ec7..5dc9d51cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,13 @@ mod static_api; mod sync; mod validate; -const AVAILABLE_SERVICES: &[&str] = &["github", "mailgun", "zulip", "crates-io"]; +const AVAILABLE_SERVICES: &[&str] = &[ + "github", + "google-workspace", + "mailgun", + "zulip", + "crates-io", +]; const USER_AGENT: &str = "https://github.com/rust-lang/team (infra@rust-lang.org)"; diff --git a/src/schema.rs b/src/schema.rs index 366dcb4c6..e7738b3c0 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -76,13 +76,22 @@ pub(crate) struct Funding { github_sponsors: bool, } -#[allow(dead_code)] -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, Debug, Clone)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub(crate) struct GoogleWorkspace { - first_name: String, - last_name: String, - account_handle: String, + pub first_name: String, + pub last_name: String, + pub account_handle: String, +} + +impl From for rust_team_data::v1::GoogleWorkspace { + fn from(gws: GoogleWorkspace) -> Self { + rust_team_data::v1::GoogleWorkspace { + first_name: gws.first_name, + last_name: gws.last_name, + account_handle: gws.account_handle, + } + } } #[allow(dead_code)] @@ -157,8 +166,8 @@ impl Person { &self.permissions } - pub(crate) fn google_workspace(&self) -> &Option { - &self.google_workspace + pub(crate) fn google_workspace(&self) -> Option<&GoogleWorkspace> { + self.google_workspace.as_ref() } pub(crate) fn validate(&self) -> Result<(), Error> { @@ -194,7 +203,6 @@ impl std::fmt::Display for TeamKind { } } -#[allow(dead_code)] #[derive(serde::Deserialize, Debug)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub(crate) struct Team { diff --git a/src/static_api.rs b/src/static_api.rs index 1e0186a95..309a60f2c 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -7,7 +7,9 @@ use anyhow::{Context as _, Error, ensure}; use indexmap::IndexMap; use log::info; use rust_team_data::v1; -use rust_team_data::v1::{BranchProtectionMode, Crate, CrateTeamOwner, RepoMember}; +use rust_team_data::v1::{ + BranchProtectionMode, Crate, CrateTeamOwner, GoogleWorkspace, RepoMember, +}; use std::collections::HashMap; use std::path::Path; @@ -531,14 +533,18 @@ fn convert_teams<'a>( let leads = team.leads(); let mut members = Vec::new(); - for github_name in &team.members(data)? { + for github_name in team.members(data)? { if let Some(person) = data.person(github_name) { members.push(v1::TeamMember { name: person.name().into(), github: (*github_name).into(), github_id: person.github_id(), is_lead: leads.contains(github_name), - roles: website_roles.get(*github_name).cloned().unwrap_or_default(), + roles: website_roles.get(github_name).cloned().unwrap_or_default(), + google_workspace: person + .google_workspace() + .cloned() + .map(GoogleWorkspace::from), }); } } @@ -557,6 +563,10 @@ fn convert_teams<'a>( .get(alum.github.as_str()) .cloned() .unwrap_or_default(), + google_workspace: person + .google_workspace() + .cloned() + .map(GoogleWorkspace::from), }); } } @@ -606,6 +616,7 @@ fn convert_teams<'a>( description: role.description.clone(), }) .collect(), + google_workspace_saml_group: team.google_workspace_saml_group(), }; team_map.insert(team.name().into(), team_data); } diff --git a/src/sync/github/tests/test_utils.rs b/src/sync/github/tests/test_utils.rs index 0b97ca0d4..711d6e606 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -287,6 +287,7 @@ impl From for v1::Team { github: (!gh_teams.is_empty()).then_some(TeamGitHub { teams: gh_teams }), website_data: None, roles: vec![], + google_workspace_saml_group: None, } } } diff --git a/src/sync/gws/api.rs b/src/sync/gws/api.rs new file mode 100644 index 000000000..cd0490b41 --- /dev/null +++ b/src/sync/gws/api.rs @@ -0,0 +1,41 @@ +use crate::sync::gws::RUST_LANG_GWS_DOMAIN; +use async_trait::async_trait; +use rust_team_data::v1::GoogleWorkspace; + +/// https://developers.google.com/workspace/admin/directory/reference/rest/v1/groups +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Group { + pub name: String, + pub email: String, +} + +/// https://developers.google.com/workspace/admin/directory/reference/rest/v1/users#UserName +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct UserName { + pub given_name: String, + pub family_name: String, +} + +/// https://developers.google.com/workspace/admin/directory/reference/rest/v1/users +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct User { + pub name: UserName, + pub primary_email: String, +} + +impl From<&GoogleWorkspace> for User { + fn from(gws: &GoogleWorkspace) -> Self { + Self { + primary_email: format!("{}@{}", gws.account_handle, RUST_LANG_GWS_DOMAIN), + name: UserName { + given_name: gws.first_name.to_string(), + family_name: gws.last_name.to_string(), + }, + } + } +} + +#[async_trait] +pub(crate) trait GoogleWorkspaceApiClient { + async fn get_users(&self) -> anyhow::Result>; +} diff --git a/src/sync/gws/mod.rs b/src/sync/gws/mod.rs new file mode 100644 index 000000000..14bda2c1b --- /dev/null +++ b/src/sync/gws/mod.rs @@ -0,0 +1,275 @@ +mod api; + +use crate::sync::gws::api::{GoogleWorkspaceApiClient, Group, User}; +use std::collections::BTreeSet; +use std::fmt::Debug; + +pub(crate) const RUST_LANG_GWS_DOMAIN: &str = "rust-lang.org"; + +#[allow(dead_code)] +#[derive(Debug, PartialEq)] +pub(crate) enum GoogleGroupDiff { + Create(Group), + Delete(Group), +} + +#[allow(dead_code)] +#[derive(Debug, PartialEq)] +pub(crate) enum GoogleUserDiff { + Create(User), + Delete(User), +} + +/// A diff between the team repo and the state on Google Workspace +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) struct GoogleWorkspaceDiff { + google_groups: Vec, + google_users: Vec, +} + +/// The engine that evaluates diffs between our current configuration and +/// the actual state in Google Workspace +#[allow(dead_code)] +pub(crate) struct SyncGoogleWorkspace { + actual_users: Vec, + configured_teams: Vec, +} + +#[allow(dead_code)] +impl SyncGoogleWorkspace { + pub async fn new( + teams: Vec, + gws_api_client: Box, + ) -> anyhow::Result { + let gws_users = gws_api_client.get_users().await?; + let sync = Self { + actual_users: gws_users, + configured_teams: teams, + }; + Ok(sync) + } + + pub(crate) fn diff_all(&self) -> anyhow::Result { + let google_groups_diff = self.diff_groups()?; + let google_accounts_diff = self.diff_users()?; + + let diff = GoogleWorkspaceDiff { + google_groups: google_groups_diff, + google_users: google_accounts_diff, + }; + Ok(diff) + } + + fn diff_groups(&self) -> anyhow::Result> { + Ok(vec![]) + } + + fn diff_users(&self) -> anyhow::Result> { + let declared_users = self + .configured_teams + .iter() + .filter(|team| team.google_workspace_saml_group.unwrap_or_default()) + .flat_map(|team| team.members.iter()) + .filter_map(|member| member.google_workspace.as_ref().map(User::from)) + .collect::>(); + + let declared_emails = declared_users + .iter() + .map(|user| user.primary_email.as_str()) + .collect::>(); + + let actual_emails = self + .actual_users + .iter() + .map(|user| user.primary_email.as_str()) + .collect::>(); + + let diffs = declared_users + .iter() + .filter(|u| !actual_emails.contains(u.primary_email.as_str())) + .map(|u| GoogleUserDiff::Create(u.clone())) + .chain( + self.actual_users + .iter() + .filter(|u| !declared_emails.contains(u.primary_email.as_str())) + .map(|u| GoogleUserDiff::Delete(u.clone())), + ) + .collect(); + + Ok(diffs) + } +} + +#[cfg(test)] +mod tests { + pub mod rust_team_data_fakes { + use rust_team_data::v1::{GoogleWorkspace, Team, TeamKind, TeamMember}; + + pub fn normal_member(name: &str) -> TeamMember { + TeamMember { + name: name.into(), + github: name.into(), + github_id: 1234567, + is_lead: false, + roles: vec![], + google_workspace: None, + } + } + + pub fn privileged_member(name: &str, surname: &str) -> TeamMember { + TeamMember { + google_workspace: Some(GoogleWorkspace { + first_name: name.into(), + last_name: surname.into(), + account_handle: format!("{name}.{surname}"), + }), + ..normal_member(name) + } + } + + pub fn normal_team(name: &str, members: Vec) -> Team { + Team { + kind: TeamKind::Team, + name: name.to_string(), + github: None, + website_data: None, + subteam_of: None, + top_level: Some(true), + alumni: vec![], + roles: vec![], + google_workspace_saml_group: None, + members, + } + } + + pub fn privileged_team(name: &str, members: Vec) -> Team { + Team { + google_workspace_saml_group: Some(true), + ..normal_team(name, members) + } + } + } + + use crate::sync::gws::api::{GoogleWorkspaceApiClient, User, UserName}; + use crate::sync::gws::tests::rust_team_data_fakes::{privileged_member, privileged_team}; + use crate::sync::gws::{ + GoogleUserDiff, GoogleWorkspaceDiff, RUST_LANG_GWS_DOMAIN, SyncGoogleWorkspace, + }; + use async_trait::async_trait; + use rust_team_data::v1::Team; + + struct FakeGoogleWorkspace { + users: Vec, + } + + #[async_trait] + impl GoogleWorkspaceApiClient for FakeGoogleWorkspace { + async fn get_users(&self) -> anyhow::Result> { + Ok(self.users.clone()) + } + } + + fn google_user(name: &str, surname: &str) -> User { + User { + name: UserName { + given_name: name.into(), + family_name: surname.into(), + }, + primary_email: format!("{name}.{surname}@{RUST_LANG_GWS_DOMAIN}"), + } + } + + async fn run_sync( + gws_api_client: Box, + teams: Vec, + ) -> GoogleWorkspaceDiff { + let sync = SyncGoogleWorkspace::new(teams, gws_api_client) + .await + .expect("cannot create sync"); + + let google_users_diff = sync.diff_users().expect("cannot diff accounts"); + let google_groups_diff = sync.diff_groups().expect("cannot diff groups"); + GoogleWorkspaceDiff { + google_users: google_users_diff, + google_groups: google_groups_diff, + } + } + + fn fake_gws_client(users: Vec) -> Box { + let fake_gws = FakeGoogleWorkspace { users }; + Box::new(fake_gws) + } + + #[tokio::test] + async fn diff_spots_nothing() { + let google_users = vec![ + google_user("ubiratan", "soares"), + google_user("marco", "ieni"), + ]; + + let teams = vec![privileged_team( + "infra-admins", + vec![ + privileged_member("ubiratan", "soares"), + privileged_member("marco", "ieni"), + ], + )]; + + let gws_api_client = fake_gws_client(google_users); + + let diff = run_sync(gws_api_client, teams).await; + assert!(diff.google_users.is_empty()); + assert!(diff.google_groups.is_empty()); + } + + #[tokio::test] + async fn diff_spots_user_creation() { + let google_users = vec![ + google_user("ubiratan", "soares"), + google_user("marco", "ieni"), + ]; + + let teams = vec![privileged_team( + "infra-admins", + vec![ + privileged_member("ubiratan", "soares"), + privileged_member("marco", "ieni"), + privileged_member("emily", "albini"), + ], + )]; + + let gws_api_client = fake_gws_client(google_users); + + let diff = run_sync(gws_api_client, teams).await; + let expected = vec![GoogleUserDiff::Create(google_user("emily", "albini"))]; + + assert_eq!(diff.google_users, expected); + assert!(diff.google_groups.is_empty()); + } + + #[tokio::test] + async fn diff_spots_user_deletion() { + let google_users = vec![ + google_user("ubiratan", "soares"), + google_user("marco", "ieni"), + google_user("emily", "albini"), + ]; + + let teams = vec![privileged_team( + "infra-admins", + vec![privileged_member("emily", "albini")], + )]; + + let gws_api_client = fake_gws_client(google_users); + + let diff = run_sync(gws_api_client, teams).await; + let expected = vec![ + GoogleUserDiff::Delete(google_user("ubiratan", "soares")), + GoogleUserDiff::Delete(google_user("marco", "ieni")), + ]; + + assert_eq!(diff.google_users, expected); + assert!(diff.google_groups.is_empty()); + } +} diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 307a0d1de..cebbd878b 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,5 +1,6 @@ mod crates_io; mod github; +mod gws; mod mailgun; pub mod team_api; pub mod utils; @@ -78,6 +79,9 @@ pub async fn run_sync_team( diff.apply(&sync).await?; } } + "google-workspace" => { + println!("google-workspace: nothing to diff"); + } _ => panic!("unknown service: {service}"), } }