From af352dce4e4b0175c72930a36460cd98f038de6c 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 | 10 + src/main.rs | 8 +- src/schema.rs | 10 +- src/static_api.rs | 19 +- src/sync/github/tests/test_utils.rs | 1 + src/sync/gws/api.rs | 40 +++ src/sync/gws/mod.rs | 289 ++++++++++++++++++ src/sync/mod.rs | 4 + .../_expected/v1/archived-teams.json | 6 +- .../_expected/v1/archived-teams/wg-test.json | 6 +- tests/static-api/_expected/v1/teams.json | 33 +- .../static-api/_expected/v1/teams/alumni.json | 3 +- tests/static-api/_expected/v1/teams/foo.json | 6 +- .../_expected/v1/teams/infra-admins.json | 3 +- .../_expected/v1/teams/leaderless.json | 3 +- .../_expected/v1/teams/leads-permissions.json | 9 +- .../_expected/v1/teams/wg-test.json | 9 +- 17 files changed, 425 insertions(+), 34 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..25af6bbc2 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 name: String, + pub surname: String, + pub account_handle: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -36,6 +45,7 @@ pub struct TeamMember { pub is_lead: bool, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub roles: Vec, + 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..765308f7e 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -76,13 +76,12 @@ 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, } #[allow(dead_code)] @@ -194,7 +193,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..e07bba3ef 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; @@ -539,6 +541,13 @@ fn convert_teams<'a>( github_id: person.github_id(), is_lead: leads.contains(github_name), roles: website_roles.get(*github_name).cloned().unwrap_or_default(), + google_workspace: person.google_workspace().clone().map(|gws| { + GoogleWorkspace { + name: gws.first_name.clone(), + surname: gws.last_name.clone(), + account_handle: gws.account_handle.clone(), + } + }), }); } } @@ -557,6 +566,13 @@ fn convert_teams<'a>( .get(alum.github.as_str()) .cloned() .unwrap_or_default(), + google_workspace: person.google_workspace().clone().map(|gws| { + GoogleWorkspace { + name: gws.first_name, + surname: gws.last_name, + account_handle: gws.account_handle, + } + }), }); } } @@ -606,6 +622,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..ac0665492 --- /dev/null +++ b/src/sync/gws/api.rs @@ -0,0 +1,40 @@ +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, PartialEq)] +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, PartialEq)] +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!("{}@rust-lang.org", gws.account_handle), + name: UserName { + given_name: gws.name.to_string(), + family_name: gws.surname.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..4f81a0bc7 --- /dev/null +++ b/src/sync/gws/mod.rs @@ -0,0 +1,289 @@ +mod api; + +use crate::sync::gws::api::{GoogleWorkspaceApiClient, Group, User}; +use std::fmt::Debug; + +#[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 evaluate 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) async fn diff_all(&self) -> anyhow::Result { + let google_groups_diff = self.diff_groups().await?; + let google_accounts_diff = self.diff_users().await?; + + let diff = GoogleWorkspaceDiff { + google_groups: google_groups_diff, + google_users: google_accounts_diff, + }; + Ok(diff) + } + + async fn diff_groups(&self) -> anyhow::Result> { + Ok(vec![]) + } + + async fn diff_users(&self) -> anyhow::Result> { + let members_from_gws_enabled_teams = self + .configured_teams + .iter() + .filter_map(|team| match team.google_workspace_saml_group { + None => None, + Some(opt_in) => { + if opt_in { + Some(&team.members) + } else { + None + } + } + }) + .flatten() + .collect::>(); + + let declared_users = members_from_gws_enabled_teams + .iter() + .filter_map(|member| member.google_workspace.as_ref().map(User::from)) + .collect::>(); + + let users_to_create = declared_users + .iter() + .filter(|declared_account| { + !self.matches_by_user_email(&declared_account.primary_email, &self.actual_users) + }) + .map(|declared_account| GoogleUserDiff::Create(declared_account.clone())) + .collect::>(); + + let users_to_remove = self + .actual_users + .iter() + .filter(|actual_account| { + !self.matches_by_user_email(&actual_account.primary_email, &declared_users) + }) + .map(|actual_account| GoogleUserDiff::Delete(actual_account.clone())) + .collect::>(); + + let mut diffs = Vec::new(); + diffs.extend(users_to_create); + diffs.extend(users_to_remove); + + Ok(diffs) + } + + fn matches_by_user_email(&self, email: &str, users: &[User]) -> bool { + users.iter().any(|account| account.primary_email == email) + } + + fn matches_by_saml_group(&self, target: &Group, groups: &[Group]) -> bool { + groups.iter().any(|group| group == target) + } +} + +#[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 { + name: name.into(), + surname: 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, 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.org"), + } + } + + 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().await.expect("cannot diff accounts"); + let google_groups_diff = sync.diff_groups().await.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..94b7c0319 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!("Nothing to diff"); + } _ => panic!("unknown service: {service}"), } } diff --git a/tests/static-api/_expected/v1/archived-teams.json b/tests/static-api/_expected/v1/archived-teams.json index 2ed1008c4..4e1db6c7f 100644 --- a/tests/static-api/_expected/v1/archived-teams.json +++ b/tests/static-api/_expected/v1/archived-teams.json @@ -9,13 +9,15 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null }, { "name": "Fifth user", "github": "user-5", "github_id": 5, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "github": null, diff --git a/tests/static-api/_expected/v1/archived-teams/wg-test.json b/tests/static-api/_expected/v1/archived-teams/wg-test.json index dc09d1d9a..68f730f23 100644 --- a/tests/static-api/_expected/v1/archived-teams/wg-test.json +++ b/tests/static-api/_expected/v1/archived-teams/wg-test.json @@ -8,13 +8,15 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null }, { "name": "Fifth user", "github": "user-5", "github_id": 5, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "github": null, diff --git a/tests/static-api/_expected/v1/teams.json b/tests/static-api/_expected/v1/teams.json index f271016fd..5c81d2267 100644 --- a/tests/static-api/_expected/v1/teams.json +++ b/tests/static-api/_expected/v1/teams.json @@ -8,7 +8,8 @@ "name": "Fifth user", "github": "user-5", "github_id": 5, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], @@ -26,13 +27,15 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": true + "is_lead": true, + "google_workspace": null }, { "name": "First user", "github": "user-1", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], @@ -78,7 +81,8 @@ "name": "Test Admin", "github": "test-admin", "github_id": 7, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], @@ -96,7 +100,8 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], @@ -142,19 +147,22 @@ "name": "Sixth user", "github": "user-6", "github_id": 6, - "is_lead": true + "is_lead": true, + "google_workspace": null }, { "name": "Third user", "github": "user-3", "github_id": 3, - "is_lead": false + "is_lead": false, + "google_workspace": null }, { "name": "Fourth user", "github": "user-4", "github_id": 4, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], @@ -183,7 +191,8 @@ "is_lead": true, "roles": [ "convener" - ] + ], + "google_workspace": null } ], "alumni": [ @@ -191,13 +200,15 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null }, { "name": "Fifth user", "github": "user-5", "github_id": 5, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "github": null, diff --git a/tests/static-api/_expected/v1/teams/alumni.json b/tests/static-api/_expected/v1/teams/alumni.json index 1d2ce62b0..3d6c1dca6 100644 --- a/tests/static-api/_expected/v1/teams/alumni.json +++ b/tests/static-api/_expected/v1/teams/alumni.json @@ -7,7 +7,8 @@ "name": "Fifth user", "github": "user-5", "github_id": 5, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], diff --git a/tests/static-api/_expected/v1/teams/foo.json b/tests/static-api/_expected/v1/teams/foo.json index 345c5e151..3b77e8b5f 100644 --- a/tests/static-api/_expected/v1/teams/foo.json +++ b/tests/static-api/_expected/v1/teams/foo.json @@ -8,13 +8,15 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": true + "is_lead": true, + "google_workspace": null }, { "name": "First user", "github": "user-1", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], diff --git a/tests/static-api/_expected/v1/teams/infra-admins.json b/tests/static-api/_expected/v1/teams/infra-admins.json index db547f0f5..a2a0dc14b 100644 --- a/tests/static-api/_expected/v1/teams/infra-admins.json +++ b/tests/static-api/_expected/v1/teams/infra-admins.json @@ -7,7 +7,8 @@ "name": "Test Admin", "github": "test-admin", "github_id": 7, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], diff --git a/tests/static-api/_expected/v1/teams/leaderless.json b/tests/static-api/_expected/v1/teams/leaderless.json index 264d1faa4..0656915a1 100644 --- a/tests/static-api/_expected/v1/teams/leaderless.json +++ b/tests/static-api/_expected/v1/teams/leaderless.json @@ -8,7 +8,8 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], diff --git a/tests/static-api/_expected/v1/teams/leads-permissions.json b/tests/static-api/_expected/v1/teams/leads-permissions.json index 22ed65c8e..af90ef7f3 100644 --- a/tests/static-api/_expected/v1/teams/leads-permissions.json +++ b/tests/static-api/_expected/v1/teams/leads-permissions.json @@ -8,19 +8,22 @@ "name": "Sixth user", "github": "user-6", "github_id": 6, - "is_lead": true + "is_lead": true, + "google_workspace": null }, { "name": "Third user", "github": "user-3", "github_id": 3, - "is_lead": false + "is_lead": false, + "google_workspace": null }, { "name": "Fourth user", "github": "user-4", "github_id": 4, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "alumni": [], diff --git a/tests/static-api/_expected/v1/teams/wg-test.json b/tests/static-api/_expected/v1/teams/wg-test.json index 1ae2a1ca9..eb3dcc6cc 100644 --- a/tests/static-api/_expected/v1/teams/wg-test.json +++ b/tests/static-api/_expected/v1/teams/wg-test.json @@ -10,7 +10,8 @@ "is_lead": true, "roles": [ "convener" - ] + ], + "google_workspace": null } ], "alumni": [ @@ -18,13 +19,15 @@ "name": "Zeroth user", "github": "user-0", "github_id": 0, - "is_lead": false + "is_lead": false, + "google_workspace": null }, { "name": "Fifth user", "github": "user-5", "github_id": 5, - "is_lead": false + "is_lead": false, + "google_workspace": null } ], "github": null,