diff --git a/Cargo.lock b/Cargo.lock
index 8c37057c0e..60fc2659d2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -964,6 +964,7 @@ dependencies = [
"ipnetwork",
"lettre",
"minijinja",
+ "mockall",
"moka",
"oauth2",
"object_store",
@@ -972,6 +973,7 @@ dependencies = [
"parking_lot",
"prometheus",
"rand",
+ "regex",
"reqwest",
"scheduled-thread-pool",
"secrecy",
@@ -1426,6 +1428,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "downcast"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
+
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -1614,6 +1622,12 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fragile"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa"
+
[[package]]
name = "futf"
version = "0.1.5"
@@ -2526,6 +2540,33 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "mockall"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48"
+dependencies = [
+ "cfg-if",
+ "downcast",
+ "fragile",
+ "lazy_static",
+ "mockall_derive",
+ "predicates",
+ "predicates-tree",
+]
+
+[[package]]
+name = "mockall_derive"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2"
+dependencies = [
+ "cfg-if",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.48",
+]
+
[[package]]
name = "moka"
version = "0.12.2"
@@ -3003,6 +3044,33 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+[[package]]
+name = "predicates"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dfc28575c2e3f19cb3c73b93af36460ae898d426eba6fc15b9bd2a5220758a0"
+dependencies = [
+ "anstyle",
+ "itertools 0.11.0",
+ "predicates-core",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
[[package]]
name = "primeorder"
version = "0.13.6"
@@ -3978,6 +4046,12 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "termtree"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76"
+
[[package]]
name = "thiserror"
version = "1.0.56"
diff --git a/Cargo.toml b/Cargo.toml
index 61faa840b6..416473e628 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -90,6 +90,7 @@ ipnetwork = "=0.20.0"
tikv-jemallocator = { version = "=0.5.4", features = ['unprefixed_malloc_on_supported_platforms', 'profiling'] }
lettre = { version = "=0.11.3", default-features = false, features = ["file-transport", "smtp-transport", "native-tls", "hostname", "builder"] }
minijinja = "=1.0.11"
+mockall = "=0.12.1"
moka = { version = "=0.12.2", features = ["future"] }
oauth2 = { version = "=4.4.2", default-features = false, features = ["reqwest"] }
object_store = { version = "=0.9.0", features = ["aws"] }
@@ -128,4 +129,5 @@ crates_io_test_db = { path = "crates_io_test_db" }
claims = "=0.7.1"
googletest = "=0.10.0"
insta = { version = "=1.34.0", features = ["json", "redactions"] }
+regex = "=1.10.2"
tokio = "=1.35.1"
diff --git a/src/admin/enqueue_job.rs b/src/admin/enqueue_job.rs
index 15376d49db..04f55ce67e 100644
--- a/src/admin/enqueue_job.rs
+++ b/src/admin/enqueue_job.rs
@@ -3,6 +3,7 @@ use crate::schema::{background_jobs, crates};
use crate::worker::jobs;
use anyhow::Result;
use crates_io_worker::BackgroundJob;
+use diesel::dsl::exists;
use diesel::prelude::*;
use secrecy::{ExposeSecret, SecretString};
@@ -30,6 +31,11 @@ pub enum Command {
#[arg()]
name: String,
},
+ SyncAdmins {
+ /// Force a sync even if one is already in progress
+ #[arg(long)]
+ force: bool,
+ },
}
pub fn run(command: Command) -> Result<()> {
@@ -58,6 +64,28 @@ pub fn run(command: Command) -> Result<()> {
} => {
jobs::DumpDb::new(database_url.expose_secret(), target_name).enqueue(conn)?;
}
+ Command::SyncAdmins { force } => {
+ if !force {
+ // By default, we don't want to enqueue a sync if one is already
+ // in progress. If a sync fails due to e.g. an expired pinned
+ // certificate we don't want to keep adding new jobs to the
+ // queue, since the existing job will be retried until it
+ // succeeds.
+
+ let query = background_jobs::table
+ .filter(background_jobs::job_type.eq(jobs::SyncAdmins::JOB_NAME));
+
+ if diesel::select(exists(query)).get_result(conn)? {
+ info!(
+ "Did not enqueue {}, existing job already in progress",
+ jobs::SyncAdmins::JOB_NAME
+ );
+ return Ok(());
+ }
+ }
+
+ jobs::SyncAdmins.enqueue(conn)?;
+ }
Command::DailyDbMaintenance => {
jobs::DailyDbMaintenance.enqueue(conn)?;
}
diff --git a/src/bin/background-worker.rs b/src/bin/background-worker.rs
index ebc1c4ce96..0c6a821839 100644
--- a/src/bin/background-worker.rs
+++ b/src/bin/background-worker.rs
@@ -18,6 +18,7 @@ use crates_io::cloudfront::CloudFront;
use crates_io::db::DieselPool;
use crates_io::fastly::Fastly;
use crates_io::storage::Storage;
+use crates_io::team_repo::TeamRepoImpl;
use crates_io::worker::{Environment, RunnerExt};
use crates_io::{config, Emails};
use crates_io::{db, ssh};
@@ -76,7 +77,8 @@ fn main() -> anyhow::Result<()> {
.expect("Couldn't build client");
let emails = Emails::from_environment(&config);
- let fastly = Fastly::from_environment(client);
+ let fastly = Fastly::from_environment(client.clone());
+ let team_repo = TeamRepoImpl::default();
let connection_pool = r2d2::Pool::builder()
.max_size(10)
@@ -90,6 +92,7 @@ fn main() -> anyhow::Result<()> {
.storage(storage)
.connection_pool(DieselPool::new_background_worker(connection_pool.clone()))
.emails(emails)
+ .team_repo(Box::new(team_repo))
.build()?;
let environment = Arc::new(environment);
diff --git a/src/certs/lets-encrypt.pem b/src/certs/lets-encrypt.pem
new file mode 100644
index 0000000000..cd44265fe8
--- /dev/null
+++ b/src/certs/lets-encrypt.pem
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
+TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
+cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
+WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
+RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
+AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
+R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
+sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
+NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
+Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
+/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
+AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
+Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
+FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
+AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
+Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
+gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
+PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
+ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
+CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
+lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
+avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
+yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
+yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
+hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
+HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
+MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
+nLRbwHOoq7hHwg==
+-----END CERTIFICATE-----
diff --git a/src/certs/mod.rs b/src/certs/mod.rs
new file mode 100644
index 0000000000..de2d1f8da5
--- /dev/null
+++ b/src/certs/mod.rs
@@ -0,0 +1 @@
+pub const LETS_ENCRYPT: &[u8] = include_bytes!("./lets-encrypt.pem");
diff --git a/src/lib.rs b/src/lib.rs
index 0243b761a7..b0af96db80 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -32,6 +32,7 @@ pub mod admin;
mod app;
pub mod auth;
pub mod boot;
+pub mod certs;
pub mod ci;
pub mod cloudfront;
pub mod config;
@@ -55,6 +56,7 @@ pub mod sql;
pub mod ssh;
pub mod storage;
pub mod tasks;
+pub mod team_repo;
mod test_util;
pub mod typosquat;
pub mod util;
diff --git a/src/team_repo.rs b/src/team_repo.rs
new file mode 100644
index 0000000000..0764ade747
--- /dev/null
+++ b/src/team_repo.rs
@@ -0,0 +1,80 @@
+//! The code in this module interacts with the
+//! repository.
+//!
+//! The [TeamRepo] trait is used to abstract away the HTTP client for testing
+//! purposes. The [TeamRepoImpl] struct is the actual implementation of
+//! the trait.
+
+use crate::certs;
+use async_trait::async_trait;
+use mockall::automock;
+use reqwest::{Certificate, Client};
+
+#[automock]
+#[async_trait]
+pub trait TeamRepo {
+ async fn get_team(&self, name: &str) -> anyhow::Result;
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Team {
+ pub name: String,
+ pub kind: String,
+ pub members: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Member {
+ pub name: String,
+ pub github: String,
+ pub github_id: i32,
+ pub is_lead: bool,
+}
+
+pub struct TeamRepoImpl {
+ client: Client,
+}
+
+impl TeamRepoImpl {
+ fn new(client: Client) -> Self {
+ TeamRepoImpl { client }
+ }
+}
+
+impl Default for TeamRepoImpl {
+ fn default() -> Self {
+ let client = build_client();
+ TeamRepoImpl::new(client)
+ }
+}
+
+fn build_client() -> Client {
+ let lets_encrypt_cert = Certificate::from_pem(certs::LETS_ENCRYPT).unwrap();
+
+ Client::builder()
+ .tls_built_in_root_certs(false)
+ .add_root_certificate(lets_encrypt_cert)
+ .build()
+ .unwrap()
+}
+
+#[async_trait]
+impl TeamRepo for TeamRepoImpl {
+ async fn get_team(&self, name: &str) -> anyhow::Result {
+ let url = format!("https://team-api.infra.rust-lang.org/v1/teams/{name}.json");
+ let response = self.client.get(url).send().await?.error_for_status()?;
+ Ok(response.json().await?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::team_repo::build_client;
+
+ /// This test is here to make sure that the client is built
+ /// correctly without panicking.
+ #[test]
+ fn test_build_client() {
+ let _client = build_client();
+ }
+}
diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs
index e434c28684..0106dd604b 100644
--- a/src/tests/util/test_app.rs
+++ b/src/tests/util/test_app.rs
@@ -7,6 +7,7 @@ use crates_io::middleware::cargo_compat::StatusCodeConfig;
use crates_io::models::token::{CrateScope, EndpointScope};
use crates_io::rate_limiter::{LimitedAction, RateLimiterConfig};
use crates_io::storage::StorageConfig;
+use crates_io::team_repo::MockTeamRepo;
use crates_io::worker::{Environment, RunnerExt};
use crates_io::{App, Emails, Env};
use crates_io_index::testing::UpstreamIndex;
@@ -80,6 +81,7 @@ impl TestApp {
index: None,
build_job_runner: false,
use_chaos_proxy: false,
+ team_repo: MockTeamRepo::new(),
}
}
@@ -204,6 +206,7 @@ pub struct TestAppBuilder {
index: Option,
build_job_runner: bool,
use_chaos_proxy: bool,
+ team_repo: MockTeamRepo,
}
impl TestAppBuilder {
@@ -259,11 +262,13 @@ impl TestAppBuilder {
index_location: index.url(),
credentials: Credentials::Missing,
};
+
let environment = Environment::builder()
.repository_config(repository_config)
.storage(app.storage.clone())
.connection_pool(app.primary_database.clone())
.emails(app.emails.clone())
+ .team_repo(Box::new(self.team_repo))
.build()
.unwrap();
@@ -351,6 +356,11 @@ impl TestAppBuilder {
self
}
+ pub fn with_team_repo(mut self, team_repo: MockTeamRepo) -> Self {
+ self.team_repo = team_repo;
+ self
+ }
+
pub fn with_replica(mut self) -> Self {
let primary = &self.config.db.primary;
diff --git a/src/tests/worker/mod.rs b/src/tests/worker/mod.rs
index 08dfb7f4e6..f8ae161e71 100644
--- a/src/tests/worker/mod.rs
+++ b/src/tests/worker/mod.rs
@@ -1 +1,2 @@
mod git;
+mod sync_admins;
diff --git a/src/tests/worker/snapshots/all__worker__sync_admins__sync_admins_job.snap b/src/tests/worker/snapshots/all__worker__sync_admins__sync_admins_job.snap
new file mode 100644
index 0000000000..990ebf240b
--- /dev/null
+++ b/src/tests/worker/snapshots/all__worker__sync_admins__sync_admins_job.snap
@@ -0,0 +1,8 @@
+---
+source: src/tests/worker/sync_admins.rs
+expression: emails
+---
+[
+ "To: existing-admin@crates.io\r\nFrom: noreply@crates.io\r\nSubject: crates.io: Admin account changes\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nNew admins have been added:\r\n\r\n- new-admin (github_id: 3)\r\n\r\nAdmin access has been revoked for:\r\n- obsolete-admin (github_id: 2)\r\n",
+ "To: obsolete-admin@crates.io\r\nFrom: noreply@crates.io\r\nSubject: crates.io: Admin account changes\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nNew admins have been added:\r\n\r\n- new-admin (github_id: 3)\r\n\r\nAdmin access has been revoked for:\r\n- obsolete-admin (github_id: 2)\r\n",
+]
diff --git a/src/tests/worker/sync_admins.rs b/src/tests/worker/sync_admins.rs
new file mode 100644
index 0000000000..68d95d19ea
--- /dev/null
+++ b/src/tests/worker/sync_admins.rs
@@ -0,0 +1,103 @@
+use crate::util::TestApp;
+use crates_io::schema::{emails, users};
+use crates_io::team_repo::{Member, MockTeamRepo, Team};
+use crates_io::worker::jobs::SyncAdmins;
+use crates_io_worker::BackgroundJob;
+use diesel::prelude::*;
+use diesel::{PgConnection, QueryResult, RunQueryDsl};
+use insta::assert_debug_snapshot;
+use regex::Regex;
+
+#[test]
+fn test_sync_admins_job() {
+ let mock_response = mock_team(
+ "crates-io",
+ vec![
+ mock_member("existing-admin", 1),
+ mock_member("new-admin", 3),
+ ],
+ );
+
+ let mut team_repo = MockTeamRepo::new();
+ team_repo
+ .expect_get_team()
+ .with(mockall::predicate::eq("crates-io-admins"))
+ .returning(move |_| Ok(mock_response.clone()));
+
+ let (app, _) = TestApp::full().with_team_repo(team_repo).empty();
+
+ app.db(|conn| create_user("existing-admin", 1, true, conn).unwrap());
+ app.db(|conn| create_user("obsolete-admin", 2, true, conn).unwrap());
+ app.db(|conn| create_user("new-admin", 3, false, conn).unwrap());
+ app.db(|conn| create_user("unrelated-user", 42, false, conn).unwrap());
+
+ let admins = app.db(|conn| get_admins(conn).unwrap());
+ let expected_admins = vec![("existing-admin".into(), 1), ("obsolete-admin".into(), 2)];
+ assert_eq!(admins, expected_admins);
+
+ app.db(|conn| SyncAdmins.enqueue(conn).unwrap());
+ app.run_pending_background_jobs();
+
+ let admins = app.db(|conn| get_admins(conn).unwrap());
+ let expected_admins = vec![("existing-admin".into(), 1), ("new-admin".into(), 3)];
+ assert_eq!(admins, expected_admins);
+
+ let email_header_regex = Regex::new(r"(Message-ID|Date): [^\r\n]+\r\n").unwrap();
+ let emails = app.as_inner().emails.mails_in_memory().unwrap();
+ let emails = emails
+ .iter()
+ .map(|(_, email)| email_header_regex.replace_all(email, ""))
+ .collect::>();
+
+ assert_debug_snapshot!(emails);
+}
+
+fn mock_team(name: impl Into, members: Vec) -> Team {
+ Team {
+ name: name.into(),
+ kind: "marker-team".to_string(),
+ members,
+ }
+}
+
+fn mock_member(name: impl Into, github_id: i32) -> Member {
+ let name = name.into();
+ let github = name.clone();
+ Member {
+ name,
+ github,
+ github_id,
+ is_lead: false,
+ }
+}
+
+fn create_user(name: &str, gh_id: i32, is_admin: bool, conn: &mut PgConnection) -> QueryResult<()> {
+ let user_id = diesel::insert_into(users::table)
+ .values((
+ users::name.eq(name),
+ users::gh_login.eq(name),
+ users::gh_id.eq(gh_id),
+ users::gh_access_token.eq("some random token"),
+ users::is_admin.eq(is_admin),
+ ))
+ .returning(users::id)
+ .get_result::(conn)?;
+
+ diesel::insert_into(emails::table)
+ .values((
+ emails::user_id.eq(user_id),
+ emails::email.eq(format!("{}@crates.io", name)),
+ emails::verified.eq(true),
+ ))
+ .execute(conn)?;
+
+ Ok(())
+}
+
+fn get_admins(conn: &mut PgConnection) -> QueryResult> {
+ users::table
+ .select((users::gh_login, users::gh_id))
+ .filter(users::is_admin.eq(true))
+ .order(users::gh_id.asc())
+ .get_results(conn)
+}
diff --git a/src/worker/environment.rs b/src/worker/environment.rs
index bc1e651cbb..94143a3694 100644
--- a/src/worker/environment.rs
+++ b/src/worker/environment.rs
@@ -2,6 +2,7 @@ use crate::cloudfront::CloudFront;
use crate::db::DieselPool;
use crate::fastly::Fastly;
use crate::storage::Storage;
+use crate::team_repo::TeamRepo;
use crate::typosquat;
use crate::Emails;
use crates_io_index::{Repository, RepositoryConfig};
@@ -25,6 +26,7 @@ pub struct Environment {
pub storage: Arc,
pub connection_pool: DieselPool,
pub emails: Emails,
+ pub team_repo: Box,
/// A lazily initialised cache of the most popular crates ready to use in typosquatting checks.
#[builder(default, setter(skip))]
diff --git a/src/worker/jobs/mod.rs b/src/worker/jobs/mod.rs
index 61d0e263ce..916bd16022 100644
--- a/src/worker/jobs/mod.rs
+++ b/src/worker/jobs/mod.rs
@@ -9,6 +9,7 @@ mod daily_db_maintenance;
pub mod dump_db;
mod git;
mod readmes;
+mod sync_admins;
mod typosquat;
mod update_downloads;
@@ -16,6 +17,7 @@ pub use self::daily_db_maintenance::DailyDbMaintenance;
pub use self::dump_db::DumpDb;
pub use self::git::{NormalizeIndex, SquashIndex, SyncToGitIndex, SyncToSparseIndex};
pub use self::readmes::RenderAndUploadReadme;
+pub use self::sync_admins::SyncAdmins;
pub use self::typosquat::CheckTyposquat;
pub use self::update_downloads::UpdateDownloads;
diff --git a/src/worker/jobs/sync_admins.rs b/src/worker/jobs/sync_admins.rs
new file mode 100644
index 0000000000..8819e8381b
--- /dev/null
+++ b/src/worker/jobs/sync_admins.rs
@@ -0,0 +1,165 @@
+use crate::email::Email;
+use crate::schema::{emails, users};
+use crate::tasks::spawn_blocking;
+use crate::worker::Environment;
+use crates_io_worker::BackgroundJob;
+use diesel::prelude::*;
+use diesel::RunQueryDsl;
+use std::collections::HashSet;
+use std::fmt::{Display, Formatter};
+use std::sync::Arc;
+
+/// See .
+const TEAM_NAME: &str = "crates-io-admins";
+
+#[derive(Serialize, Deserialize)]
+pub struct SyncAdmins;
+
+impl BackgroundJob for SyncAdmins {
+ const JOB_NAME: &'static str = "sync_admins";
+
+ type Context = Arc;
+
+ async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> {
+ info!("Syncing admins from rust-lang/team repo…");
+
+ let repo_admins = ctx.team_repo.get_team(TEAM_NAME).await?.members;
+ let repo_admin_ids = repo_admins
+ .iter()
+ .map(|m| m.github_id)
+ .collect::>();
+
+ spawn_blocking::<_, _, anyhow::Error>(move || {
+ let mut conn = ctx.connection_pool.get()?;
+
+ let database_admins = users::table
+ .left_join(emails::table)
+ .select((users::gh_id, users::gh_login, emails::email.nullable()))
+ .filter(users::is_admin.eq(true))
+ .get_results::<(i32, String, Option)>(&mut conn)?;
+
+ let database_admin_ids = database_admins
+ .iter()
+ .map(|(gh_id, _, _)| *gh_id)
+ .collect::>();
+
+ let new_admin_ids = repo_admin_ids
+ .difference(&database_admin_ids)
+ .collect::>();
+
+ let new_admins = if new_admin_ids.is_empty() {
+ debug!("No new admins to add");
+ vec![]
+ } else {
+ let new_admins = repo_admins
+ .iter()
+ .filter(|m| new_admin_ids.contains(&&m.github_id))
+ .map(|m| format!("{} (github_id: {})", m.github, m.github_id))
+ .collect::>();
+
+ info!("Adding new admins: {}", new_admins.join(", "));
+
+ diesel::update(users::table)
+ .filter(users::gh_id.eq_any(new_admin_ids))
+ .set(users::is_admin.eq(true))
+ .execute(&mut conn)?;
+
+ new_admins
+ };
+
+ let obsolete_admin_ids = database_admin_ids
+ .difference(&repo_admin_ids)
+ .collect::>();
+
+ let obsolete_admins = if obsolete_admin_ids.is_empty() {
+ debug!("No obsolete admins to remove");
+ vec![]
+ } else {
+ let obsolete_admins = database_admins
+ .iter()
+ .filter(|(gh_id, _, _)| obsolete_admin_ids.contains(&gh_id))
+ .map(|(gh_id, login, _)| format!("{} (github_id: {})", login, gh_id))
+ .collect::>();
+
+ info!("Removing obsolete admins: {}", obsolete_admins.join(", "));
+
+ diesel::update(users::table)
+ .filter(users::gh_id.eq_any(obsolete_admin_ids))
+ .set(users::is_admin.eq(false))
+ .execute(&mut conn)?;
+
+ obsolete_admins
+ };
+
+ if !new_admins.is_empty() || !obsolete_admins.is_empty() {
+ let email = AdminAccountEmail::new(new_admins, obsolete_admins);
+
+ for database_admin in &database_admins {
+ let (_, _, email_address) = database_admin;
+ if let Some(email_address) = email_address {
+ if let Err(error) = ctx.emails.send(email_address, email.clone()) {
+ warn!(
+ "Failed to send email to admin {} ({}, github_id: {}): {}",
+ database_admin.1, email_address, database_admin.0, error
+ );
+ }
+ } else {
+ warn!(
+ "No email address found for admin {} (github_id: {})",
+ database_admin.1, database_admin.0
+ );
+ }
+ }
+ }
+
+ Ok(())
+ })
+ .await?;
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone)]
+struct AdminAccountEmail {
+ new_admins: Vec,
+ obsolete_admins: Vec,
+}
+
+impl AdminAccountEmail {
+ fn new(new_admins: Vec, obsolete_admins: Vec) -> Self {
+ Self {
+ new_admins,
+ obsolete_admins,
+ }
+ }
+}
+
+impl Email for AdminAccountEmail {
+ const SUBJECT: &'static str = "crates.io: Admin account changes";
+
+ fn body(&self) -> String {
+ self.to_string()
+ }
+}
+
+impl Display for AdminAccountEmail {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ if !self.new_admins.is_empty() {
+ writeln!(f, "New admins have been added:\n")?;
+ for new_admin in &self.new_admins {
+ writeln!(f, "- {}", new_admin)?;
+ }
+ writeln!(f)?;
+ }
+
+ if !self.obsolete_admins.is_empty() {
+ writeln!(f, "Admin access has been revoked for:")?;
+ for obsolete_admin in &self.obsolete_admins {
+ writeln!(f, "- {}", obsolete_admin)?;
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/worker/mod.rs b/src/worker/mod.rs
index 36e79b41b3..cfe4f5675f 100644
--- a/src/worker/mod.rs
+++ b/src/worker/mod.rs
@@ -25,6 +25,7 @@ impl RunnerExt for Runner> {
.register_job_type::()
.register_job_type::()
.register_job_type::()
+ .register_job_type::()
.register_job_type::()
.register_job_type::()
.register_job_type::()